mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat(ui): task-chat composer swaps textarea for shared MarkdownEditor
Rich composer inside the v7 comp-bar chrome: @-mentions (wired through TaskChatThread, previously dropped), data-driven /-command autocomplete via EditorAutocompleteContext, rich lists, and inline pasted/dropped images at the caret through the editor's image plugin -> issue attachments POST. Non-image files keep the chip row; the round-13 object-URL thumbnail path is superseded. Cmd/Ctrl+Enter posts natively; Shift+Tab mode-cycling is captured on the wrapper ahead of Lexical. Motion-gate comment fix in TaskChatStatusPill (doc said 100ms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
edb2812235
commit
8bbef4177a
4 files changed
+404
-228
No files matched your search
@@ -76,6 +76,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
activeRun,
|
||||
onAttachImage,
|
||||
imageUploadHandler,
|
||||
mentions,
|
||||
enableReassign,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
@@ -390,6 +391,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
disabledReason={composerDisabledReason}
|
||||
onAttachImage={onAttachImage}
|
||||
onImageUpload={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
|
||||
@@ -4,106 +4,278 @@ import type { ReactElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildAgentMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { TaskChatComposer } from "./TaskChatComposer";
|
||||
|
||||
/**
|
||||
* MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor
|
||||
* is mocked with a contenteditable bridge — typing is simulated by setting
|
||||
* textContent and dispatching an input event, imperative setMarkdown /
|
||||
* insertMarkdown mutate the same content, and imagePlugin's config is captured
|
||||
* so the inline upload handler can be exercised directly.
|
||||
*/
|
||||
const mdxEditorMockState = vi.hoisted(() => ({
|
||||
imagePluginOptions: null as { imageUploadHandler?: (file: File) => Promise<string> } | null,
|
||||
}));
|
||||
|
||||
vi.mock("@mdxeditor/editor", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref) {
|
||||
(ref as React.MutableRefObject<T | null>).current = value;
|
||||
}
|
||||
}
|
||||
|
||||
interface MockHandle {
|
||||
setMarkdown: (value: string) => void;
|
||||
insertMarkdown: (value: string) => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const MDXEditor = React.forwardRef(function MockMDXEditor(
|
||||
{
|
||||
markdown,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
markdown: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
},
|
||||
forwardedRef: React.ForwardedRef<MockHandle | null>,
|
||||
) {
|
||||
const editableRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentRef = React.useRef(markdown);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const handle = React.useMemo<MockHandle>(() => ({
|
||||
setMarkdown: (value: string) => {
|
||||
contentRef.current = value;
|
||||
if (editableRef.current) editableRef.current.textContent = value;
|
||||
},
|
||||
insertMarkdown: (value: string) => {
|
||||
const next = contentRef.current ? `${contentRef.current}${value}` : value;
|
||||
contentRef.current = next;
|
||||
if (editableRef.current) editableRef.current.textContent = next;
|
||||
onChangeRef.current?.(next);
|
||||
},
|
||||
focus: () => editableRef.current?.focus(),
|
||||
}), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editableRef.current && contentRef.current) {
|
||||
editableRef.current.textContent = contentRef.current;
|
||||
}
|
||||
setForwardedRef(forwardedRef, handle);
|
||||
return () => setForwardedRef(forwardedRef, null);
|
||||
}, [forwardedRef, handle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editableRef}
|
||||
data-testid="mdx-editor"
|
||||
contentEditable={!readOnly}
|
||||
suppressContentEditableWarning
|
||||
onInput={(e) => {
|
||||
const next = e.currentTarget.textContent ?? "";
|
||||
contentRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
CodeMirrorEditor: () => null,
|
||||
MDXEditor,
|
||||
codeBlockPlugin: () => ({}),
|
||||
codeMirrorPlugin: () => ({}),
|
||||
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
|
||||
headingsPlugin: () => ({}),
|
||||
imagePlugin: (options: { imageUploadHandler?: (file: File) => Promise<string> }) => {
|
||||
mdxEditorMockState.imagePluginOptions = options;
|
||||
return {};
|
||||
},
|
||||
linkDialogPlugin: () => ({}),
|
||||
linkPlugin: () => ({}),
|
||||
listsPlugin: () => ({}),
|
||||
markdownShortcutPlugin: () => ({}),
|
||||
quotePlugin: () => ({}),
|
||||
realmPlugin: (plugin: unknown) => plugin,
|
||||
tablePlugin: () => ({}),
|
||||
thematicBreakPlugin: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/mention-deletion", () => ({
|
||||
mentionDeletionPlugin: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/paste-normalization", () => ({
|
||||
pasteNormalizationPlugin: () => ({}),
|
||||
}));
|
||||
|
||||
const SLASH_HREF = buildSkillMentionHref("skill-1", "deploy");
|
||||
|
||||
vi.mock("../../context/EditorAutocompleteContext", () => ({
|
||||
useEditorAutocomplete: () => ({
|
||||
slashCommands: [
|
||||
{
|
||||
id: "skill:skill-1",
|
||||
kind: "skill",
|
||||
skillId: "skill-1",
|
||||
key: "deploy",
|
||||
name: "Deploy",
|
||||
slug: "deploy",
|
||||
description: null,
|
||||
href: SLASH_HREF,
|
||||
aliases: ["deploy", "Deploy"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
// jsdom has no object-URL support; the composer uses it for image previews.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-preview");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
mdxEditorMockState.imagePluginOptions = null;
|
||||
// jsdom ranges have zero-size rects; the mention menu measures the caret.
|
||||
originalRangeRect = Range.prototype.getBoundingClientRect;
|
||||
Range.prototype.getBoundingClientRect = () => ({
|
||||
x: 32, y: 24, width: 12, height: 18, top: 24, right: 44, bottom: 42, left: 32,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
Range.prototype.getBoundingClientRect = originalRangeRect;
|
||||
window.getSelection()?.removeAllRanges();
|
||||
});
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
flushSync(() => root!.render(ui));
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
flushSync(() => {
|
||||
nativeSetter.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function pressEnter(
|
||||
textarea: HTMLTextAreaElement,
|
||||
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
|
||||
) {
|
||||
flushSync(() => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", ...modifiers, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pressCmdEnter(textarea: HTMLTextAreaElement) {
|
||||
pressEnter(textarea, { metaKey: true });
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function input() {
|
||||
return container.querySelector<HTMLTextAreaElement>('[data-testid="task-chat-composer-input"]')!;
|
||||
function editable() {
|
||||
return container.querySelector<HTMLDivElement>('[data-testid="mdx-editor"]')!;
|
||||
}
|
||||
|
||||
function sendButton() {
|
||||
return container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
|
||||
}
|
||||
|
||||
/** Simulate typing: set the contenteditable's text and fire an input event. */
|
||||
function typeText(value: string) {
|
||||
const el = editable();
|
||||
flushSync(() => {
|
||||
el.textContent = value;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function pressKey(
|
||||
key: string,
|
||||
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
|
||||
) {
|
||||
flushSync(() => {
|
||||
editable().dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, ...modifiers, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pasteFiles(files: File[]) {
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files, types: ["Files"] } });
|
||||
flushSync(() => {
|
||||
editable().dispatchEvent(paste);
|
||||
});
|
||||
return paste;
|
||||
}
|
||||
|
||||
/** Place the caret at the end of the editable's first text node and announce it. */
|
||||
async function placeCaretAtEnd() {
|
||||
const textNode = editable().firstChild;
|
||||
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode!, textNode!.textContent!.length);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
flushSync(() => {
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
});
|
||||
// Mention detection defers via requestAnimationFrame after input events.
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
}
|
||||
|
||||
function autocompleteOption(matchText: string) {
|
||||
const menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]');
|
||||
expect(menu).toBeTruthy();
|
||||
const option = Array.from(menu!.querySelectorAll<HTMLButtonElement>('button[type="button"]'))
|
||||
.find((node) => node.textContent?.includes(matchText));
|
||||
expect(option).toBeTruthy();
|
||||
return option!;
|
||||
}
|
||||
|
||||
describe("TaskChatComposer", () => {
|
||||
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
expect(sendButton().disabled).toBe(true);
|
||||
setTextareaValue(input(), " hello there ");
|
||||
typeText(" hello there ");
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
|
||||
pressCmdEnter(input());
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
|
||||
expect(input().value).toBe("");
|
||||
expect(editable().textContent).toBe("");
|
||||
});
|
||||
|
||||
it("submits on Ctrl+Enter", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
setTextareaValue(input(), "hello");
|
||||
pressEnter(input(), { ctrlKey: true });
|
||||
typeText("hello");
|
||||
pressKey("Enter", { ctrlKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined);
|
||||
});
|
||||
|
||||
it("does not submit on plain Enter or Shift+Enter (newline stays in the draft)", async () => {
|
||||
it("does not submit on plain Enter or Shift+Enter (newline stays with the editor)", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
setTextareaValue(input(), "line one");
|
||||
pressEnter(input());
|
||||
pressEnter(input(), { shiftKey: true });
|
||||
typeText("line one");
|
||||
pressKey("Enter");
|
||||
pressKey("Enter", { shiftKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe("line one");
|
||||
expect(editable().textContent).toBe("line one");
|
||||
});
|
||||
|
||||
it("cycles the pending mode with Shift+Tab and applies it on submit", async () => {
|
||||
@@ -117,16 +289,12 @@ describe("TaskChatComposer", () => {
|
||||
expect(chip.getAttribute("data-pending-work-mode")).toBe("standard");
|
||||
expect(chip.textContent).toContain("Agent");
|
||||
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
pressKey("Tab", { shiftKey: true });
|
||||
expect(chip.getAttribute("data-pending-work-mode")).toBe("planning");
|
||||
expect(chip.textContent).toContain("Plan");
|
||||
|
||||
setTextareaValue(input(), "do the plan");
|
||||
pressCmdEnter(input());
|
||||
typeText("do the plan");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
|
||||
@@ -144,8 +312,8 @@ describe("TaskChatComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
setTextareaValue(input(), "wake up");
|
||||
pressCmdEnter(input());
|
||||
typeText("wake up");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined);
|
||||
@@ -165,51 +333,49 @@ describe("TaskChatComposer", () => {
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-attach"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uploads pasted files via onAttachImage and inserts the reference into the draft", async () => {
|
||||
it("wires the editor's inline image upload to onAttachImage and returns the attachment URL", async () => {
|
||||
const onAttachImage = vi.fn().mockResolvedValue({
|
||||
contentPath: "/attachments/shot.png",
|
||||
originalFilename: "shot.png",
|
||||
});
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
|
||||
|
||||
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [file] } });
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(paste);
|
||||
});
|
||||
await flushAsync();
|
||||
const handler = mdxEditorMockState.imagePluginOptions?.imageUploadHandler;
|
||||
expect(handler).toBeTypeOf("function");
|
||||
|
||||
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
|
||||
await expect(handler!(file)).resolves.toBe("/attachments/shot.png");
|
||||
expect(onAttachImage).toHaveBeenCalledWith(file);
|
||||
expect(input().value).toContain("");
|
||||
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]');
|
||||
expect(chips?.textContent).toContain("shot.png");
|
||||
// Inline images do not go through the attachment chip row.
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an object-URL thumbnail for a pasted image attachment", async () => {
|
||||
it("does not register the image plugin without an upload handler", () => {
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
|
||||
expect(mdxEditorMockState.imagePluginOptions).toBeNull();
|
||||
});
|
||||
|
||||
it("attaches pasted non-image files to the chip row and inserts a link reference", async () => {
|
||||
const onAttachImage = vi.fn().mockResolvedValue({
|
||||
contentPath: "/attachments/shot.png",
|
||||
originalFilename: "shot.png",
|
||||
contentPath: "/attachments/notes.txt",
|
||||
originalFilename: "notes.txt",
|
||||
});
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
|
||||
|
||||
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [file] } });
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(paste);
|
||||
});
|
||||
const file = new File(["plain"], "notes.txt", { type: "text/plain" });
|
||||
const paste = pasteFiles([file]);
|
||||
await flushAsync();
|
||||
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(file);
|
||||
const thumb = container.querySelector<HTMLImageElement>(
|
||||
'[data-testid="task-chat-composer-attachments"] img',
|
||||
);
|
||||
expect(thumb?.src).toBe("blob:mock-preview");
|
||||
expect(thumb?.alt).toBe("");
|
||||
// The all-non-image paste is swallowed before the editor sees it.
|
||||
expect(paste.defaultPrevented).toBe(true);
|
||||
expect(onAttachImage).toHaveBeenCalledWith(file);
|
||||
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]');
|
||||
expect(chips?.textContent).toContain("notes.txt");
|
||||
expect(chips?.textContent).toContain("Attached");
|
||||
expect(editable().textContent).toContain("[notes.txt](/attachments/notes.txt)");
|
||||
});
|
||||
|
||||
it("renders no thumbnail for non-image attachments and revokes previews on unmount", async () => {
|
||||
it("leaves pasted images to the editor's image plugin (no chip, paste not swallowed)", async () => {
|
||||
const onAttachImage = vi.fn().mockResolvedValue({
|
||||
contentPath: "/attachments/notes.txt",
|
||||
originalFilename: "notes.txt",
|
||||
@@ -218,22 +384,59 @@ describe("TaskChatComposer", () => {
|
||||
|
||||
const image = new File(["png-bytes"], "shot.png", { type: "image/png" });
|
||||
const text = new File(["plain"], "notes.txt", { type: "text/plain" });
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [image, text] } });
|
||||
const paste = pasteFiles([image, text]);
|
||||
await flushAsync();
|
||||
|
||||
// Mixed paste: the non-image is chipped, the image flows through untouched.
|
||||
expect(paste.defaultPrevented).toBe(false);
|
||||
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]')!;
|
||||
expect(chips.textContent).toContain("notes.txt");
|
||||
expect(chips.textContent).not.toContain("shot.png");
|
||||
expect(onAttachImage).toHaveBeenCalledTimes(1);
|
||||
expect(onAttachImage).toHaveBeenCalledWith(text);
|
||||
});
|
||||
|
||||
it("inserts an @-mention from the autocomplete menu and posts it", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={onAdd}
|
||||
workMode="standard"
|
||||
mentions={[{ id: "agent:a1", kind: "agent", name: "Clippy", agentId: "a1" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
typeText("@Cl");
|
||||
await placeCaretAtEnd();
|
||||
|
||||
const option = autocompleteOption("Clippy");
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(paste);
|
||||
option.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Only the image chip carries a thumbnail; the text chip stays text-only.
|
||||
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]')!;
|
||||
expect(chips.textContent).toContain("notes.txt");
|
||||
expect(chips.querySelectorAll("img")).toHaveLength(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
const expected = `[@Clippy](${buildAgentMentionHref("a1", null)}) `;
|
||||
expect(editable().textContent).toBe(expected);
|
||||
|
||||
flushSync(() => root!.unmount());
|
||||
root = null;
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-preview");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined);
|
||||
});
|
||||
|
||||
it("inserts a /-command from the autocomplete menu", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
typeText("/dep");
|
||||
await placeCaretAtEnd();
|
||||
|
||||
const option = autocompleteOption("/deploy");
|
||||
flushSync(() => {
|
||||
option.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(editable().textContent).toBe(`[/deploy](${SLASH_HREF}) `);
|
||||
});
|
||||
|
||||
it("shows the assignee combobox only when reassign is enabled, with the current label", () => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangle, ArrowUp, Check, ChevronDown, Loader2, Plus } from "lucide-react";
|
||||
@@ -15,8 +13,10 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "@/components/MarkdownEditor";
|
||||
import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
|
||||
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||
import type { MentionOption } from "@/components/MarkdownEditor";
|
||||
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
|
||||
|
||||
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
|
||||
@@ -36,6 +36,8 @@ interface TaskChatComposerProps {
|
||||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||
/** Fallback upload path: returns a URL for inline image markdown. */
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
/** Mentionable entities for the editor's @-autocomplete. */
|
||||
mentions?: MentionOption[];
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
currentAssigneeValue?: string;
|
||||
@@ -76,9 +78,6 @@ type ComposerAttachment = {
|
||||
name: string;
|
||||
status: "uploading" | "attached" | "error";
|
||||
error?: string;
|
||||
/** Object URL for an immediate thumbnail while (and after) uploading. */
|
||||
previewUrl?: string;
|
||||
isImage?: boolean;
|
||||
};
|
||||
|
||||
/** Local duplicate of IssueChatThread's module-private helper (same rule). */
|
||||
@@ -99,17 +98,20 @@ function parseAssigneeValue(value: string): CommentReassignment | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||
function escapeMarkdownLabel(name: string): string {
|
||||
return name.replace(/[[\]]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer for the redesigned thread (v7 spec): textarea over a 32px comp-bar
|
||||
* of [attach] [mode chip] … [assignee] [send]. The mode chip is a status-chip
|
||||
* rectangle carrying the pending mode's hue; the composer chrome itself stays
|
||||
* neutral. Shift+Tab cycles modes; the picked mode is applied on submit.
|
||||
* Attachments upload via `onAttachImage` (or the inline `onImageUpload`
|
||||
* fallback) from the + button, paste, or drag-drop.
|
||||
* Composer for the redesigned thread (v7 spec): the shared MarkdownEditor
|
||||
* (rich lists, @-mentions, /-commands, inline pasted images) over a 32px
|
||||
* comp-bar of [attach] [mode chip] … [assignee] [send]. The mode chip is a
|
||||
* status-chip rectangle carrying the pending mode's hue; the composer chrome
|
||||
* itself stays neutral. Shift+Tab cycles modes (captured before Lexical);
|
||||
* Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter stays a
|
||||
* newline / next list item. Pasted or dropped images upload through
|
||||
* `onAttachImage` (or the `onImageUpload` fallback) and land inline at the
|
||||
* caret via the editor's image plugin; non-image files keep the chip row.
|
||||
*/
|
||||
export function TaskChatComposer({
|
||||
onAdd,
|
||||
@@ -120,6 +122,7 @@ export function TaskChatComposer({
|
||||
placeholder,
|
||||
onAttachImage,
|
||||
onImageUpload,
|
||||
mentions,
|
||||
enableReassign = false,
|
||||
reassignOptions,
|
||||
currentAssigneeValue = "",
|
||||
@@ -130,26 +133,10 @@ export function TaskChatComposer({
|
||||
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
|
||||
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const previewUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Object URLs created for image previews must be revoked or they leak the
|
||||
// underlying blobs for the page's lifetime.
|
||||
useEffect(() => {
|
||||
const urls = previewUrlsRef.current;
|
||||
return () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function releasePreview(url: string | undefined) {
|
||||
if (!url) return;
|
||||
URL.revokeObjectURL(url);
|
||||
previewUrlsRef.current.delete(url);
|
||||
}
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const bodyRef = useRef(body);
|
||||
bodyRef.current = body;
|
||||
|
||||
const modeMeta = workModeMetaFor(pendingMode);
|
||||
const canAcceptFiles = Boolean(onAttachImage || onImageUpload);
|
||||
@@ -160,40 +147,23 @@ export function TaskChatComposer({
|
||||
const assigneeName = assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel;
|
||||
const effectivePlaceholder = placeholder ?? modePlaceholder(pendingMode, assigneeName);
|
||||
|
||||
function insertReference(name: string, url: string, asImage: boolean) {
|
||||
const safeName = name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = asImage ? `` : `[${safeName}](${url})`;
|
||||
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||||
/** Upload an image and return its URL for inline `` markdown. */
|
||||
async function uploadInlineImage(file: File): Promise<string> {
|
||||
if (onAttachImage) {
|
||||
const attachment = await onAttachImage(file);
|
||||
if (attachment?.contentPath) return attachment.contentPath;
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
if (onImageUpload) return onImageUpload(file);
|
||||
throw new Error("This file type cannot be attached here");
|
||||
}
|
||||
|
||||
async function attachFile(file: File) {
|
||||
/** Non-image files: attach to the task and track in the chip row. */
|
||||
async function attachNonImageFile(file: File) {
|
||||
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
// Create the preview up front so the thumbnail appears while uploading.
|
||||
const previewUrl =
|
||||
isImage && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : undefined;
|
||||
if (previewUrl) previewUrlsRef.current.add(previewUrl);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{ id, name: file.name, status: "uploading", previewUrl, isImage: isImage || undefined },
|
||||
]);
|
||||
setAttachments((prev) => [...prev, { id, name: file.name, status: "uploading" }]);
|
||||
try {
|
||||
if (onAttachImage) {
|
||||
const attachment = await onAttachImage(file);
|
||||
const name = attachment?.originalFilename ?? file.name;
|
||||
if (attachment?.contentPath) {
|
||||
insertReference(name, attachment.contentPath, file.type.startsWith("image/"));
|
||||
}
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, name, status: "attached" } : item)),
|
||||
);
|
||||
} else if (onImageUpload && file.type.startsWith("image/")) {
|
||||
const url = await onImageUpload(file);
|
||||
insertReference(file.name, url, true);
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, status: "attached" } : item)),
|
||||
);
|
||||
} else {
|
||||
if (!onAttachImage) {
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
@@ -201,7 +171,16 @@ export function TaskChatComposer({
|
||||
: item,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const attachment = await onAttachImage(file);
|
||||
const name = attachment?.originalFilename ?? file.name;
|
||||
if (attachment?.contentPath) {
|
||||
editorRef.current?.insertMarkdown(`[${escapeMarkdownLabel(name)}](${attachment.contentPath}) `);
|
||||
}
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, name, status: "attached" } : item)),
|
||||
);
|
||||
} catch (err) {
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
@@ -213,57 +192,61 @@ export function TaskChatComposer({
|
||||
}
|
||||
}
|
||||
|
||||
async function attachFiles(files: Iterable<File>) {
|
||||
for (const file of files) {
|
||||
await attachFile(file);
|
||||
/** Images picked from the + button go inline at the caret, like paste/drop. */
|
||||
async function attachPickedFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
await attachNonImageFile(file);
|
||||
return;
|
||||
}
|
||||
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
||||
try {
|
||||
const url = await uploadInlineImage(file);
|
||||
editorRef.current?.insertMarkdown(``);
|
||||
} catch (err) {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name: file.name,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Upload failed",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const files = evt.target.files;
|
||||
if (files && files.length > 0) void attachFiles(Array.from(files));
|
||||
if (files && files.length > 0) {
|
||||
void (async () => {
|
||||
for (const file of Array.from(files)) await attachPickedFile(file);
|
||||
})();
|
||||
}
|
||||
evt.target.value = "";
|
||||
}
|
||||
|
||||
function handlePaste(evt: ReactClipboardEvent<HTMLTextAreaElement>) {
|
||||
/**
|
||||
* Pasted image files fall through to the editor's image plugin (inline at
|
||||
* the caret); non-image files are attached to the task here. Only swallow
|
||||
* the paste when it carries no images the plugin should handle.
|
||||
*/
|
||||
function handlePasteCapture(evt: ReactClipboardEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles) return;
|
||||
const files = Array.from(evt.clipboardData?.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
evt.preventDefault();
|
||||
void attachFiles(files);
|
||||
}
|
||||
|
||||
function handleFileDragEnter(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragOver(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
|
||||
function handleFileDragLeave(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||
}
|
||||
|
||||
function handleFileDrop(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
const files = evt.dataTransfer?.files;
|
||||
if (files && files.length > 0) void attachFiles(Array.from(files));
|
||||
const nonImages = files.filter((file) => !file.type.startsWith("image/"));
|
||||
if (nonImages.length === 0) return;
|
||||
if (nonImages.length === files.length) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
void (async () => {
|
||||
for (const file of nonImages) await attachNonImageFile(file);
|
||||
})();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const trimmed = body.trim();
|
||||
const trimmed = bodyRef.current.trim();
|
||||
if (!trimmed || submitting || disabled) return;
|
||||
|
||||
const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue;
|
||||
@@ -277,10 +260,7 @@ export function TaskChatComposer({
|
||||
await onWorkModeChange(pendingMode);
|
||||
}
|
||||
await onAdd(trimmed, reopen, reassignment);
|
||||
setAttachments((prev) => {
|
||||
for (const item of prev) releasePreview(item.previewUrl);
|
||||
return [];
|
||||
});
|
||||
setAttachments([]);
|
||||
setPendingAssignee(null);
|
||||
} catch {
|
||||
setBody(trimmed); // restore on failure
|
||||
@@ -293,36 +273,35 @@ export function TaskChatComposer({
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-input bg-card p-2 shadow-(--shadow-extract-7) transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15",
|
||||
isDragOver && "ring-2 ring-primary/40",
|
||||
)}
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
onKeyDownCapture={(e) => {
|
||||
// Shift+Tab cycles the pending mode; captured on the wrapper so it
|
||||
// wins over Lexical's list-outdent binding inside the editor.
|
||||
if (disabled) return;
|
||||
if (e.key === "Tab" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPendingMode((mode) => nextWorkMode(mode));
|
||||
}
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Cmd/Ctrl+Enter posts; plain Enter inserts a newline (Enter alone
|
||||
// was too easy to trip while drafting).
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setPendingMode((mode) => nextWorkMode(mode));
|
||||
}
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? (disabledReason ?? "Composer disabled") : effectivePlaceholder}
|
||||
rows={2}
|
||||
className="w-full resize-none bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60"
|
||||
data-testid="task-chat-composer-input"
|
||||
/>
|
||||
<div data-testid="task-chat-composer-input">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder={disabled ? (disabledReason ?? "Composer disabled") : effectivePlaceholder}
|
||||
readOnly={disabled}
|
||||
mentions={mentions}
|
||||
onSubmit={() => void submit()}
|
||||
imageUploadHandler={canAcceptFiles ? uploadInlineImage : undefined}
|
||||
onDropFile={canAcceptFiles ? attachNonImageFile : undefined}
|
||||
bordered={false}
|
||||
className={cn(disabled && "opacity-60")}
|
||||
contentClassName="max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mb-1 flex flex-wrap gap-1 px-1" data-testid="task-chat-composer-attachments">
|
||||
@@ -336,13 +315,6 @@ export function TaskChatComposer({
|
||||
: "border-border bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{attachment.isImage && attachment.previewUrl ? (
|
||||
<img
|
||||
src={attachment.previewUrl}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{attachment.status === "uploading" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
|
||||
) : attachment.status === "error" ? (
|
||||
@@ -370,7 +342,6 @@ export function TaskChatComposer({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ function liveElapsedLabel(ms?: number): string | null {
|
||||
return `${Math.floor(s / 60)}m ${(s % 60).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** Elapsed ms since `startedAtMs`, ticking every 100ms while `live`. */
|
||||
/** Elapsed ms since `startedAtMs`, ticking ten times a second while `live`. */
|
||||
function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): number | undefined {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user