-
Notifications
You must be signed in to change notification settings - Fork 231
feat: detect large pasted content and render as file preview #352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
baba2f6
a9e2b45
c306c13
51ccc2a
2acef31
0b498f6
7dd36ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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; | ||
|
|
@@ -311,7 +340,7 @@ export const PromptInputActionAddAttachments = ({ | |
| return ( | ||
| <DropdownMenuItem | ||
| {...props} | ||
| onSelect={(e) => { | ||
| onSelect={(e: Event) => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. type fix |
||
| e.preventDefault(); | ||
| attachments.openFileDialog(); | ||
| }} | ||
|
|
@@ -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]); | ||
| } | ||
| }; | ||
|
|
||
|
|
@@ -908,6 +948,204 @@ export const PromptInputTextarea = ({ | |
| ); | ||
| }; | ||
|
|
||
| // ============================================================================ | ||
| // Pasted content card + modal | ||
| // ============================================================================ | ||
|
|
||
| export interface PromptInputPastedContentCardProps { | ||
| attachment: FileUIPart & { id: string }; | ||
| onRemove: () => void; | ||
| } | ||
|
|
||
| export const PromptInputPastedContentCard = ({ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 = ({ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
|
||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Full width for
items-startto work, and spacing to avoid cards touching edges