feat(ui): task-chat non-image attachment chips on shadcn base/attachment

Adds ui/attachment.tsx (base-lyra registry port, @base-ui/react — the
current name of @base-ui-components/react) plus the shimmer /
scroll-fade-x / scrollbar-none utilities it expects. Composer chips get
kind icon, name, size, remove x, uploading/error states; file references
now ride as trailing [name](contentPath) lines appended at submit
(MDXEditor insertMarkdown collapses paragraph breaks, so no in-editor
insert), and an attached chip alone enables send. Posted bubbles fold
link-only reference lines back into the same chips (task-chat-attachments
extractor); prose-woven links keep their sentence. MarkdownEditor's
imperative insertMarkdown now focuses before inserting — it silently
no-oped on a never-focused editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:37 -07:00
1 parent 8bbef4177a
commit 58a5e1cccb
4 files changed
+255 -60

No files matched your search

+54 -13
View File
@@ -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 (
<div className={cn("tc-enter-bubble group flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{!isHuman && item.authorName ? (
@@ -53,19 +66,47 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
) : null}
</span>
) : null}
<div
className={cn(
"max-w-(--pct-85) break-words px-3.5 py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-(--liveness-blue) text-white"
: "rounded-2xl rounded-bl-sm bg-(--bubble-agent) text-foreground",
item.optimistic ? "opacity-80" : null,
)}
>
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
{bodyText.length > 0 ? (
<div
className={cn(
"max-w-(--pct-85) break-words px-3.5 py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-(--liveness-blue) text-white"
: "rounded-2xl rounded-bl-sm bg-(--bubble-agent) text-foreground",
item.optimistic ? "opacity-80" : null,
)}
>
<MarkdownBody softBreaks linkIssueReferences>
{bodyText}
</MarkdownBody>
</div>
) : null}
{attachmentRefs.length > 0 ? (
<AttachmentGroup
className="max-w-(--pct-85)"
data-testid="task-chat-bubble-attachments"
>
{attachmentRefs.map((ref) => {
const kind = fileKindForName(ref.name);
const KindIcon = kind.icon;
return (
<Attachment key={ref.url} size="sm" className={cn(item.optimistic && "opacity-80")}>
<AttachmentMedia>
<KindIcon aria-hidden />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="max-w-48">{ref.name}</AttachmentTitle>
<AttachmentDescription className="max-w-48">{kind.label}</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label={`Open ${ref.name}`}
render={<a href={ref.url} target="_blank" rel="noreferrer" />}
/>
</Attachment>
);
})}
</AttachmentGroup>
) : null}
{item.optimistic ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.optimistic === "queued" ? "Queued" : "Sending…"}
@@ -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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" onAttachImage={onAttachImage} />);
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<HTMLButtonElement>('[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(<TaskChatComposer onAdd={onAdd} workMode="standard" onAttachImage={onAttachImage} />);
typeText("Please review this.");
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const send = container.querySelector<HTMLButtonElement>('[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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const remove = container.querySelector<HTMLButtonElement>('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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
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 () => {
@@ -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({
</div>
{attachments.length > 0 ? (
<div className="mb-1 flex flex-wrap gap-1 px-1" data-testid="task-chat-composer-attachments">
{attachments.map((attachment) => (
<span
key={attachment.id}
className={cn(
"inline-flex min-w-0 items-center gap-1 rounded-md border px-2 py-0.5 text-xs",
attachment.status === "error"
? "border-destructive/50 bg-destructive/10 text-destructive"
: "border-border bg-muted/30 text-muted-foreground",
)}
>
{attachment.status === "uploading" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
) : attachment.status === "error" ? (
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
) : (
<Check className="h-3 w-3 shrink-0" aria-hidden />
)}
<span className="max-w-40 truncate font-medium text-foreground">{attachment.name}</span>
<span className="shrink-0">
·{" "}
{attachment.status === "uploading"
? "Uploading…"
: attachment.status === "error"
? (attachment.error ?? "Upload failed")
: "Attached"}
</span>
</span>
))}
</div>
<AttachmentGroup className="mb-1 px-1" data-testid="task-chat-composer-attachments">
{attachments.map((attachment) => {
const kind = fileKindForName(attachment.name);
const KindIcon = kind.icon;
const sizeLabel = formatFileSize(attachment.size);
return (
<Attachment
key={attachment.id}
size="sm"
state={
attachment.status === "uploading"
? "uploading"
: attachment.status === "error"
? "error"
: "done"
}
>
<AttachmentMedia>
{attachment.status === "uploading" ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<KindIcon aria-hidden />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="max-w-48">{attachment.name}</AttachmentTitle>
<AttachmentDescription className="max-w-48">
{attachment.status === "uploading"
? "Uploading…"
: attachment.status === "error"
? (attachment.error ?? "Upload failed")
: [kind.label, sizeLabel].filter(Boolean).join(" · ")}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${attachment.name}`}
onClick={() =>
setAttachments((prev) => prev.filter((item) => item.id !== attachment.id))
}
>
<X aria-hidden />
</AttachmentAction>
</AttachmentActions>
</Attachment>
);
})}
</AttachmentGroup>
) : null}
<div className="mt-1 flex items-center gap-2">
@@ -428,7 +474,7 @@ export function TaskChatComposer({
<button
type="button"
onClick={() => void submit()}
disabled={disabled || submitting || body.trim().length === 0}
disabled={disabled || submitting || (body.trim().length === 0 && attachedRefs.length === 0)}
title="Send (⌘+Enter)"
aria-label="Send"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-transform hover:scale-105 disabled:scale-100 disabled:bg-muted disabled:text-muted-foreground"
+38
View File
@@ -737,6 +737,44 @@
--shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
}
/* Utilities expected by shadcn base components (ui/attachment.tsx). Declared
with @utility so variant forms (e.g. group-data-[state=uploading]:shimmer)
compile under Tailwind v4. */
@utility shimmer {
--shimmer-base: var(--foreground);
--shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent);
background: linear-gradient(
90deg,
var(--shimmer-base) 0%,
var(--shimmer-base) 40%,
var(--shimmer-highlight) 50%,
var(--shimmer-base) 60%,
var(--shimmer-base) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer-text-slide 2.5s linear infinite;
}
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility scroll-fade-x {
mask-image: linear-gradient(
to right,
transparent,
black 12px,
black calc(100% - 12px),
transparent
);
}
@media (prefers-reduced-motion: reduce) {
.shimmer-text {
animation: none;