From 8bbef4177ac6c7125f980a7d99792c7e99f26688 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sat, 1 Aug 2026 22:12:13 -0700 Subject: [PATCH] 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 --- ui/src/components/TaskChatThread.tsx | 2 + .../task-chat/TaskChatComposer.test.tsx | 379 ++++++++++++++---- .../components/task-chat/TaskChatComposer.tsx | 249 +++++------- .../task-chat/TaskChatStatusPill.tsx | 2 +- 4 files changed, 404 insertions(+), 228 deletions(-) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 8e644581073..62b79b6cef2 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 2860cc9ba1a..568473b2941 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -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 } | null, +})); + +vi.mock("@mdxeditor/editor", async () => { + const React = await import("react"); + + function setForwardedRef(ref: React.ForwardedRef, value: T | null) { + if (typeof ref === "function") { + ref(value); + return; + } + if (ref) { + (ref as React.MutableRefObject).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, + ) { + const editableRef = React.useRef(null); + const contentRef = React.useRef(markdown); + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + + const handle = React.useMemo(() => ({ + 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 ( +
{ + 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 }) => { + 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('[data-testid="task-chat-composer-input"]')!; +function editable() { + return container.querySelector('[data-testid="mdx-editor"]')!; } function sendButton() { return container.querySelector('[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('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(); 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(); - 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(); - 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(); - 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(); + 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(); - 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( - '[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( + , + ); + + 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(); + + 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", () => { diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 58c1c2a1d72..604a7ea0d25 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -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; /** Fallback upload path: returns a URL for inline image markdown. */ onImageUpload?: (file: File) => Promise; + /** 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) { - 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(workMode); const [pendingAssignee, setPendingAssignee] = useState(null); const [attachments, setAttachments] = useState([]); - const [isDragOver, setIsDragOver] = useState(false); - const dragDepthRef = useRef(0); const fileInputRef = useRef(null); - const previewUrlsRef = useRef>(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(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 { + 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) { - 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) { 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) { + /** + * 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) { if (!canAcceptFiles) return; const files = Array.from(evt.clipboardData?.files ?? []); if (files.length === 0) return; - evt.preventDefault(); - void attachFiles(files); - } - - function handleFileDragEnter(evt: ReactDragEvent) { - if (!canAcceptFiles || !hasFilePayload(evt)) return; - evt.preventDefault(); - dragDepthRef.current += 1; - setIsDragOver(true); - } - - function handleFileDragOver(evt: ReactDragEvent) { - if (!canAcceptFiles || !hasFilePayload(evt)) return; - evt.preventDefault(); - evt.dataTransfer.dropEffect = "copy"; - } - - function handleFileDragLeave(evt: ReactDragEvent) { - 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) { - 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({
{ + // 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} > -