Upload image via chat (#686)

This commit is contained in:
Will Chen
2025-07-22 15:45:30 -07:00
committed by GitHub
parent de73445766
commit 9edd0fa80f
16 changed files with 509 additions and 118 deletions

View File

@@ -1,7 +1,8 @@
import { FileText, X } from "lucide-react";
import { FileText, X, MessageSquare, Upload } from "lucide-react";
import type { FileAttachment } from "@/ipc/ipc_types";
interface AttachmentsListProps {
attachments: File[];
attachments: FileAttachment[];
onRemove: (index: number) => void;
}
@@ -13,40 +14,50 @@ export function AttachmentsList({
return (
<div className="px-2 pt-2 flex flex-wrap gap-1">
{attachments.map((file, index) => (
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center bg-muted rounded-md px-2 py-1 text-xs gap-1"
title={`${file.name} (${(file.size / 1024).toFixed(1)}KB)`}
title={`${attachment.file.name} (${(attachment.file.size / 1024).toFixed(1)}KB)`}
>
{file.type.startsWith("image/") ? (
<div className="relative group">
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="w-5 h-5 object-cover rounded"
onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
<div className="absolute hidden group-hover:block top-6 left-0 z-10">
<div className="flex items-center gap-1">
{attachment.type === "upload-to-codebase" ? (
<Upload size={12} className="text-blue-600" />
) : (
<MessageSquare size={12} className="text-green-600" />
)}
{attachment.file.type.startsWith("image/") ? (
<div className="relative group">
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg"
src={URL.createObjectURL(attachment.file)}
alt={attachment.file.name}
className="w-5 h-5 object-cover rounded"
onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
<div className="absolute hidden group-hover:block top-6 left-0 z-10">
<img
src={URL.createObjectURL(attachment.file)}
alt={attachment.file.name}
className="max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg"
onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
onError={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
</div>
</div>
</div>
) : (
<FileText size={12} />
)}
<span className="truncate max-w-[120px]">{file.name}</span>
) : (
<FileText size={12} />
)}
</div>
<span className="truncate max-w-[120px]">{attachment.file.name}</span>
<button
onClick={() => onRemove(index)}
className="hover:bg-muted-foreground/20 rounded-full p-0.5"

View File

@@ -14,7 +14,6 @@ import {
Database,
ChevronsUpDown,
ChevronsDownUp,
Paperclip,
ChartColumnIncreasing,
SendHorizontalIcon,
} from "lucide-react";
@@ -58,6 +57,7 @@ import { useVersions } from "@/hooks/useVersions";
import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList";
import { DragDropOverlay } from "./DragDropOverlay";
import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
import { showError, showExtraFilesToast } from "@/lib/toast";
import { ChatInputControls } from "../ChatInputControls";
import { ChatErrorBox } from "./ChatErrorBox";
@@ -89,10 +89,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
// Use the attachments hook
const {
attachments,
fileInputRef,
isDraggingOver,
handleAttachmentClick,
handleFileChange,
handleFileSelect,
removeAttachment,
handleDragOver,
handleDragLeave,
@@ -342,29 +340,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
<div className="pl-2 pr-1 flex items-center justify-between pb-2">
<div className="flex items-center">
<ChatInputControls showContextFilesPicker={true} />
{/* File attachment button */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={handleAttachmentClick}
title="Attach files"
size="sm"
>
<Paperclip size={20} />
</Button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</TooltipProvider>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
{/* File attachment dropdown */}
<FileAttachmentDropdown
onFileSelect={handleFileSelect}
disabled={isStreaming}
/>
</div>

View File

@@ -0,0 +1,133 @@
import { Paperclip, MessageSquare, Upload } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { useRef } from "react";
interface FileAttachmentDropdownProps {
onFileSelect: (
files: FileList,
type: "chat-context" | "upload-to-codebase",
) => void;
disabled?: boolean;
className?: string;
}
export function FileAttachmentDropdown({
onFileSelect,
disabled,
className,
}: FileAttachmentDropdownProps) {
const chatContextFileInputRef = useRef<HTMLInputElement>(null);
const uploadToCodebaseFileInputRef = useRef<HTMLInputElement>(null);
const handleChatContextClick = () => {
chatContextFileInputRef.current?.click();
};
const handleUploadToCodebaseClick = () => {
uploadToCodebaseFileInputRef.current?.click();
};
const handleFileChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: "chat-context" | "upload-to-codebase",
) => {
if (e.target.files && e.target.files.length > 0) {
onFileSelect(e.target.files, type);
// Clear the input value so the same file can be selected again
e.target.value = "";
}
};
return (
<>
<TooltipProvider>
<Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
title="Attach files"
className={className}
>
<Paperclip size={20} />
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={handleChatContextClick}
className="py-3 px-4"
>
<MessageSquare size={16} className="mr-2" />
Attach file as chat context
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
Example use case: screenshot of the app to point out a UI
issue
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={handleUploadToCodebaseClick}
className="py-3 px-4"
>
<Upload size={16} className="mr-2" />
Upload file to codebase
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
Example use case: add an image to use for your app
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Hidden file inputs */}
<input
type="file"
data-testid="chat-context-file-input"
ref={chatContextFileInputRef}
onChange={(e) => handleFileChange(e, "chat-context")}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
<input
type="file"
data-testid="upload-to-codebase-file-input"
ref={uploadToCodebaseFileInputRef}
onChange={(e) => handleFileChange(e, "upload-to-codebase")}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
</>
);
}

View File

@@ -1,4 +1,4 @@
import { SendIcon, StopCircleIcon, Paperclip } from "lucide-react";
import { SendIcon, StopCircleIcon } from "lucide-react";
import type React from "react";
import { useEffect, useRef } from "react";
@@ -9,6 +9,7 @@ import { useStreamChat } from "@/hooks/useStreamChat";
import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList";
import { DragDropOverlay } from "./DragDropOverlay";
import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
import { usePostHog } from "posthog-js/react";
import { HomeSubmitOptions } from "@/pages/home";
import { ChatInputControls } from "../ChatInputControls";
@@ -28,10 +29,8 @@ export function HomeChatInput({
// Use the attachments hook
const {
attachments,
fileInputRef,
isDraggingOver,
handleAttachmentClick,
handleFileChange,
handleFileSelect,
removeAttachment,
handleDragOver,
handleDragLeave,
@@ -111,22 +110,11 @@ export function HomeChatInput({
disabled={isStreaming} // Should ideally reflect if *any* stream is happening
/>
{/* File attachment button */}
<button
onClick={handleAttachmentClick}
className="px-2 py-2 mt-1 mr-1 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
{/* File attachment dropdown */}
<FileAttachmentDropdown
className="mt-1 mr-1"
onFileSelect={handleFileSelect}
disabled={isStreaming}
title="Attach files"
>
<Paperclip size={20} />
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
{isStreaming ? (