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:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:14 -07:00
1 parent edb2812235
commit 8bbef4177a
4 files changed
+404 -228

No files matched your search

+2
View File
@@ -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("![shot.png](/attachments/shot.png)");
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", () => {
+110 -139
View File
@@ -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})` : `[${safeName}](${url})`;
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
/** Upload an image and return its URL for inline `![](src)` 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(`![${escapeMarkdownLabel(file.name)}](${url})`);
} 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(() => {