mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
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:
1 parent
8bbef4177a
commit
58a5e1cccb
4 files changed
+255
-60
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user