diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index db353504ab9..bf27073f46f 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -2,6 +2,16 @@ import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { AgentIcon } from "@/components/AgentIconPicker"; +import { + Attachment, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, + AttachmentTrigger, +} from "@/components/ui/attachment"; +import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments"; import type { TaskChatMessageItem } from "./task-chat-model"; interface TaskChatBubbleProps { @@ -32,6 +42,9 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) { } const isHuman = item.author === "human"; + // Non-image file references ("[name](/api/attachments/…/content)") render as + // attachment chips under the bubble; link-only lines leave the body text. + const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text); return (
{!isHuman && item.authorName ? ( @@ -53,19 +66,47 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) { ) : null} ) : null} -
- - {item.text} - -
+ {bodyText.length > 0 ? ( +
+ + {bodyText} + +
+ ) : null} + {attachmentRefs.length > 0 ? ( + + {attachmentRefs.map((ref) => { + const kind = fileKindForName(ref.name); + const KindIcon = kind.icon; + return ( + + + + + + {ref.name} + {kind.label} + + } + /> + + ); + })} + + ) : null} {item.optimistic ? ( {item.optimistic === "queued" ? "Queued" : "Sending…"} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 568473b2941..3304877979a 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -34,7 +34,7 @@ vi.mock("@mdxeditor/editor", async () => { interface MockHandle { setMarkdown: (value: string) => void; insertMarkdown: (value: string) => void; - focus: () => void; + focus: (callback?: () => void) => void; } const MDXEditor = React.forwardRef(function MockMDXEditor( @@ -65,7 +65,10 @@ vi.mock("@mdxeditor/editor", async () => { if (editableRef.current) editableRef.current.textContent = next; onChangeRef.current?.(next); }, - focus: () => editableRef.current?.focus(), + focus: (callback?: () => void) => { + editableRef.current?.focus(); + callback?.(); + }, }), []); React.useEffect(() => { @@ -355,12 +358,13 @@ describe("TaskChatComposer", () => { expect(mdxEditorMockState.imagePluginOptions).toBeNull(); }); - it("attaches pasted non-image files to the chip row and inserts a link reference", async () => { + it("attaches pasted non-image files to the chip row and posts a link reference", async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); - render(); + render(); const file = new File(["plain"], "notes.txt", { type: "text/plain" }); const paste = pasteFiles([file]); @@ -371,8 +375,74 @@ describe("TaskChatComposer", () => { 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)"); + // base/attachment chip: kind · size description and a settled state. + expect(chips?.textContent).toContain("Text · 5 B"); + expect(chips?.querySelector('[data-slot="attachment"]')?.getAttribute("data-state")).toBe("done"); + + // The editor stays prose-only; the reference rides along at submit time, + // and an attached chip alone is enough to enable send. + expect(editable().textContent ?? "").not.toContain("notes.txt"); + const send = container.querySelector('[data-testid="task-chat-composer-send"]')!; + expect(send.disabled).toBe(false); + flushSync(() => send.click()); + await flushAsync(); + expect(onAdd).toHaveBeenCalledWith( + "[notes.txt](/attachments/notes.txt)", + undefined, + undefined, + ); + // Chips clear once the message posts. + expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')).toBeNull(); + }); + + it("appends file references after typed prose on submit", async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + const onAttachImage = vi.fn().mockResolvedValue({ + contentPath: "/attachments/notes.txt", + originalFilename: "notes.txt", + }); + render(); + + typeText("Please review this."); + pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); + await flushAsync(); + + const send = container.querySelector('[data-testid="task-chat-composer-send"]')!; + flushSync(() => send.click()); + await flushAsync(); + expect(onAdd).toHaveBeenCalledWith( + "Please review this.\n\n[notes.txt](/attachments/notes.txt)", + undefined, + undefined, + ); + }); + + it("removes an attachment chip via its remove button", async () => { + const onAttachImage = vi.fn().mockResolvedValue({ + contentPath: "/attachments/notes.txt", + originalFilename: "notes.txt", + }); + render(); + + pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); + await flushAsync(); + + const remove = container.querySelector('button[aria-label="Remove notes.txt"]'); + expect(remove).not.toBeNull(); + flushSync(() => remove!.click()); + expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')).toBeNull(); + }); + + it("shows an error-state chip when a non-image upload fails", async () => { + const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large")); + render(); + + pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); + await flushAsync(); + + const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]'); + expect(chips?.querySelector('[data-slot="attachment"]')?.getAttribute("data-state")).toBe("error"); + expect(chips?.textContent).toContain("Too large"); }); it("leaves pasted images to the editor's image plugin (no chip, paste not swallowed)", async () => { diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 604a7ea0d25..febb9f7f2ad 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -6,13 +6,24 @@ import { type CSSProperties, } from "react"; import { cn } from "@/lib/utils"; -import { AlertTriangle, ArrowUp, Check, ChevronDown, Loader2, Plus } from "lucide-react"; +import { ArrowUp, Check, ChevronDown, Loader2, Plus, X } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, +} from "@/components/ui/attachment"; +import { fileKindForName, formatFileSize } from "./task-chat-attachments"; import { MarkdownEditor, type MarkdownEditorRef } from "@/components/MarkdownEditor"; import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta"; import type { InlineEntityOption } from "@/components/InlineEntitySelector"; @@ -76,8 +87,11 @@ function modePlaceholder(mode: IssueWorkMode, agentName: string): string { type ComposerAttachment = { id: string; name: string; + size?: number; status: "uploading" | "attached" | "error"; error?: string; + /** Set once uploaded; the submit path appends `[name](contentPath)` lines. */ + contentPath?: string; }; /** Local duplicate of IssueChatThread's module-private helper (same rule). */ @@ -111,7 +125,9 @@ function escapeMarkdownLabel(name: string): string { * 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. + * caret via the editor's image plugin; non-image files render as shadcn + * base/attachment chips (kind icon · name · size, remove ×, uploading/error + * states) between the editor and the comp-bar. */ export function TaskChatComposer({ onAdd, @@ -161,7 +177,7 @@ export function TaskChatComposer({ /** 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)}`; - setAttachments((prev) => [...prev, { id, name: file.name, status: "uploading" }]); + setAttachments((prev) => [...prev, { id, name: file.name, size: file.size, status: "uploading" }]); try { if (!onAttachImage) { setAttachments((prev) => @@ -175,11 +191,12 @@ export function TaskChatComposer({ } 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)), + prev.map((item) => + item.id === id + ? { ...item, name, status: "attached", contentPath: attachment?.contentPath } + : item, + ), ); } catch (err) { setAttachments((prev) => @@ -208,6 +225,7 @@ export function TaskChatComposer({ { id, name: file.name, + size: file.size, status: "error", error: err instanceof Error ? err.message : "Upload failed", }, @@ -245,10 +263,20 @@ export function TaskChatComposer({ })(); } + // Uploaded file references ride along as trailing `[name](contentPath)` + // lines — the bubble renderer folds those link-only lines back into chips. + const attachedRefs = attachments.filter( + (item) => item.status === "attached" && item.contentPath, + ); + async function submit() { const trimmed = bodyRef.current.trim(); - if (!trimmed || submitting || disabled) return; + if ((!trimmed && attachedRefs.length === 0) || submitting || disabled) return; + const refLines = attachedRefs + .map((item) => `[${escapeMarkdownLabel(item.name)}](${item.contentPath})`) + .join("\n"); + const fullBody = [trimmed, refLines].filter(Boolean).join("\n\n"); const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue; const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined; const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined; @@ -259,11 +287,11 @@ export function TaskChatComposer({ if (pendingMode !== workMode && onWorkModeChange) { await onWorkModeChange(pendingMode); } - await onAdd(trimmed, reopen, reassignment); + await onAdd(fullBody, reopen, reassignment); setAttachments([]); setPendingAssignee(null); } catch { - setBody(trimmed); // restore on failure + setBody(trimmed); // restore on failure (chips stay for retry) } finally { setSubmitting(false); } @@ -304,36 +332,54 @@ export function TaskChatComposer({
{attachments.length > 0 ? ( -
- {attachments.map((attachment) => ( - - {attachment.status === "uploading" ? ( - - ) : attachment.status === "error" ? ( - - ) : ( - - )} - {attachment.name} - - ·{" "} - {attachment.status === "uploading" - ? "Uploading…" - : attachment.status === "error" - ? (attachment.error ?? "Upload failed") - : "Attached"} - - - ))} -
+ + {attachments.map((attachment) => { + const kind = fileKindForName(attachment.name); + const KindIcon = kind.icon; + const sizeLabel = formatFileSize(attachment.size); + return ( + + + {attachment.status === "uploading" ? ( + + ) : ( + + )} + + + {attachment.name} + + {attachment.status === "uploading" + ? "Uploading…" + : attachment.status === "error" + ? (attachment.error ?? "Upload failed") + : [kind.label, sizeLabel].filter(Boolean).join(" · ")} + + + + + setAttachments((prev) => prev.filter((item) => item.id !== attachment.id)) + } + > + + + + + ); + })} + ) : null}
@@ -428,7 +474,7 @@ export function TaskChatComposer({