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({