Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/elements/src/attachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const Attachments = ({
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
"flex items-start w-full p-3",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full width for items-start to work, and spacing to avoid cards touching edges

Image

variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className
Expand Down
240 changes: 239 additions & 1 deletion packages/elements/src/prompt-input.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
"use client";

import {
Attachment,
AttachmentPreview,
AttachmentRemove,
Attachments,
} from "@repo/elements/attachments";
import { Button } from "@repo/shadcn-ui/components/ui/button";
import {
Command,
CommandEmpty,
Expand All @@ -9,6 +16,13 @@ import {
CommandList,
CommandSeparator,
} from "@repo/shadcn-ui/components/ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@repo/shadcn-ui/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
Expand Down Expand Up @@ -37,7 +51,10 @@ import { Spinner } from "@repo/shadcn-ui/components/ui/spinner";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
import {
CopyIcon,
CornerDownLeftIcon,
DownloadIcon,
FileTextIcon,
ImageIcon,
PlusIcon,
SquareIcon,
Expand Down Expand Up @@ -84,6 +101,18 @@ export interface TextInputContext {
clear: () => void;
}

/** Minimum pasted text length to show as attachment card instead of inline */
const PASTE_CARD_THRESHOLD = 2000;
const PASTED_TEXT_FILENAME = "pasted-text.txt";

function isPastedTextAttachment(file: FileUIPart & { id: string }): boolean {
return (
file.type === "file" &&
file.mediaType === "text/plain" &&
file.filename === PASTED_TEXT_FILENAME
);
}

export interface PromptInputControllerProps {
textInput: TextInputContext;
attachments: AttachmentsContext;
Expand Down Expand Up @@ -311,7 +340,7 @@ export const PromptInputActionAddAttachments = ({
return (
<DropdownMenuItem
{...props}
onSelect={(e) => {
onSelect={(e: Event) => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type fix

e.preventDefault();
attachments.openFileDialog();
}}
Expand Down Expand Up @@ -878,6 +907,17 @@ export const PromptInputTextarea = ({
if (files.length > 0) {
event.preventDefault();
attachments.add(files);
return;
}

// Handle long text paste as file attachment
const text = event.clipboardData.getData("text/plain");
if (text.length > PASTE_CARD_THRESHOLD) {
event.preventDefault();
const textFile = new File([text], PASTED_TEXT_FILENAME, {
type: "text/plain",
});
attachments.add([textFile]);
}
};

Expand Down Expand Up @@ -908,6 +948,204 @@ export const PromptInputTextarea = ({
);
};

// ============================================================================
// Pasted content card + modal
// ============================================================================

export interface PromptInputPastedContentCardProps {
attachment: FileUIPart & { id: string };
onRemove: () => void;
}

export const PromptInputPastedContentCard = ({
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pasted context card

Image

attachment,
onRemove,
}: PromptInputPastedContentCardProps) => {
const [content, setContent] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);

useEffect(() => {
if (!attachment.url) {
return;
}
let cancelled = false;
const load = async () => {
try {
const res = await fetch(attachment.url ?? "");
const text = await res.text();
if (!cancelled) {
setContent(text);
}
} catch {
if (!cancelled) {
setContent("");
}
}
};
load();
return () => {
cancelled = true;
};
}, [attachment.url]);

const handleCopy = useCallback(() => {
if (content !== null) {
navigator.clipboard.writeText(content).catch(() => undefined);
}
}, [content]);

const handleDownload = useCallback(() => {
if (content === null) {
return;
}
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = PASTED_TEXT_FILENAME;
a.click();
URL.revokeObjectURL(url);
}, [content]);

const handleCardClick = useCallback(() => {
setModalOpen(true);
}, []);

const handleCardKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setModalOpen(true);
}
}, []);

return (
<>
<div
className={cn(
"group relative flex cursor-pointer select-none items-center gap-1 rounded-md font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"h-8 border border-border px-1.5"
)}>
<button
aria-label="View pasted content"
className={cn(
"flex items-center gap-1 min-w-0 flex-1",
)}
onClick={handleCardClick}
onKeyDown={handleCardKeyDown}
type="button"
>
<FileTextIcon className="size-3 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs">
{PASTED_TEXT_FILENAME}
</span>
</button>
<Button
aria-label="Remove pasted content"
className="size-6 shrink-0 rounded p-0 opacity-70 hover:opacity-100"
onClick={onRemove}
size="icon-sm"
type="button"
variant="ghost"
>
<XIcon className="size-3" />
</Button>
</div>
<Dialog onOpenChange={setModalOpen} open={modalOpen}>
<DialogContent
className="flex max-h-[85vh] flex-col gap-4 sm:max-w-[90vw]"
showCloseButton
>
<DialogHeader>
<DialogTitle>{PASTED_TEXT_FILENAME}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/30 p-3">
{content === null ? (
<pre className="font-mono text-sm">Loading…</pre>
) : (
<div className="flex flex-col font-mono text-sm">
{content.split("\n").map((line, i) => (
<div className="flex" key={i}>
<span
aria-hidden
className="shrink-0 select-none pr-3 text-right text-muted-foreground"
>
{i + 1}
</span>
<pre className="min-w-max flex-1 whitespace-pre">
{line}
</pre>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button
disabled={content === null}
onClick={handleCopy}
type="button"
variant="outline"
>
<CopyIcon className="size-4" />
</Button>
<Button
disabled={content === null}
onClick={handleDownload}
type="button"
variant="outline"
>
<DownloadIcon className="size-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

// ============================================================================
// Attachments display (files + pasted content cards)
// ============================================================================

export const PromptInputAttachmentsDisplay = ({
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consolidate the existing attachment and the pasted content card attachment to benefit from the existing controls and context

className,
...props
}: HTMLAttributes<HTMLDivElement>) => {
const attachments = usePromptInputAttachments();

if (attachments.files.length === 0) {
return null;
}

const pastedFiles = attachments.files.filter(isPastedTextAttachment);
const otherFiles = attachments.files.filter(
(f) => !isPastedTextAttachment(f)
);

return (
<Attachments className={cn(className)} variant="inline" {...props}>
{pastedFiles.map((attachment) => (
<PromptInputPastedContentCard
attachment={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
/>
))}
{otherFiles.map((attachment) => (
<Attachment
data={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
);
};

export type PromptInputHeaderProps = Omit<
ComponentProps<typeof InputGroupAddon>,
"align"
Expand Down
31 changes: 1 addition & 30 deletions packages/examples/src/chatbot.tsx
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

custom styles through composable API

Image

Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
"use client";

import {
Attachment,
AttachmentPreview,
AttachmentRemove,
Attachments,
} from "@repo/elements/attachments";
import {
Conversation,
ConversationContent,
Expand Down Expand Up @@ -41,6 +35,7 @@ import {
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputAttachmentsDisplay,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
Expand All @@ -49,7 +44,6 @@ import {
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputAttachments,
} from "@repo/elements/prompt-input";
import {
Reasoning,
Expand Down Expand Up @@ -333,29 +327,6 @@ const mockResponses = [
"That's definitely worth exploring. From what I can see, the best way to handle this is to consider both the theoretical aspects and practical implementation details.",
];

const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments();

if (attachments.files.length === 0) {
return null;
}

return (
<Attachments variant="inline">
{attachments.files.map((attachment) => (
<Attachment
data={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
);
};

const Example = () => {
const [model, setModel] = useState<string>(models[0].id);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
Expand Down
Loading
Loading