add file attachments

This commit is contained in:
Arjun 2026-06-12 14:29:58 +05:30
parent 0ace16ede6
commit 398efd4f80
3 changed files with 103 additions and 6 deletions

View file

@ -934,6 +934,15 @@ export function setupIpcHandlers() {
}
return { path: result.filePaths[0] ?? null };
},
'dialog:openFiles': async (event, args) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win!, {
title: args.title ?? 'Attach files',
...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}),
properties: ['openFile', 'multiSelections'],
});
return { paths: result.canceled ? [] : result.filePaths };
},
// Knowledge version history handlers
'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path);

View file

@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { ArrowUp, Loader2, Square, Terminal } from 'lucide-react'
import { ArrowUp, FileText, Loader2, Plus, Square, Terminal, X } from 'lucide-react'
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'
import { MessageResponse } from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
@ -110,9 +111,13 @@ export function CodeChat({
const textareaRef = useRef<HTMLTextAreaElement>(null)
const busy = isProcessing || status === 'working' || status === 'needs-you'
// Attached file PATHS — like dragging a file into the Claude Code CLI, the
// agent receives paths and reads the files itself with its own tools.
const [attachments, setAttachments] = useState<string[]>([])
useEffect(() => {
setDraft('')
setAttachments([])
setStopping(false)
textareaRef.current?.focus()
}, [session.id])
@ -121,14 +126,47 @@ export function CodeChat({
if (!busy) setStopping(false)
}, [busy])
const addAttachments = (paths: string[]) => {
const cleaned = paths.filter(Boolean)
if (cleaned.length === 0) return
setAttachments((prev) => [...prev, ...cleaned.filter((p) => !prev.includes(p))])
}
const handlePickFiles = async () => {
const res = await window.ipc.invoke('dialog:openFiles', {
title: 'Attach files',
defaultPath: session.cwd,
})
addAttachments(res.paths)
textareaRef.current?.focus()
}
const handleDrop = (e: React.DragEvent) => {
if (!e.dataTransfer?.files?.length) return
e.preventDefault()
const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) as string[]
addAttachments(paths)
}
const canSend = (Boolean(draft.trim()) || attachments.length > 0) && !busy
const handleSend = async () => {
if (!canSend) return
const text = draft.trim()
if (!text || busy) return
const files = attachments
// The agent gets paths, CLI-style; it reads them from disk on its own.
const message = files.length > 0
? `${text || 'Look at the attached files.'}\n\nAttached files (read them from disk):\n${files.map((p) => `- ${p}`).join('\n')}`
: text
setDraft('')
const result = await send(text)
setAttachments([])
const result = await send(message)
if (!result.ok && result.error) {
toast.error(result.error)
setDraft(text)
setAttachments(files)
}
}
@ -137,8 +175,14 @@ export function CodeChat({
await stop()
}
const basename = (p: string) => p.split(/[\\/]/).pop() || p
return (
<div className="flex h-full min-h-0 flex-col">
<div
className="flex h-full min-h-0 flex-col"
onDragOver={(e) => { if (e.dataTransfer?.types?.includes('Files')) e.preventDefault() }}
onDrop={handleDrop}
>
{/* Slim header — session controls live in the Code view's middle header */}
<div className="flex items-center gap-2 border-b px-4 py-2">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
@ -207,6 +251,28 @@ export function CodeChat({
borderless textarea, round primary send / destructive stop). */}
<div className="p-3">
<div className="rowboat-chat-input mx-auto w-full max-w-3xl rounded-lg border border-border bg-background shadow-none">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
{attachments.map((p) => (
<span
key={p}
title={p}
className="group inline-flex max-w-[260px] items-center gap-1.5 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-1.5 text-xs"
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{basename(p)}</span>
<button
type="button"
onClick={() => setAttachments((prev) => prev.filter((x) => x !== p))}
aria-label="Remove attachment"
className="flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="px-4 pb-2 pt-4">
<Textarea
ref={textareaRef}
@ -224,6 +290,19 @@ export function CodeChat({
/>
</div>
<div className="flex items-center gap-2 px-3 pb-3">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void handlePickFiles()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Attach files"
>
<Plus className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Attach files the agent reads them from disk (or drag & drop)</TooltipContent>
</Tooltip>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<Terminal className="size-3.5 shrink-0" />
<span className="truncate">Direct straight to {AGENT_LABEL[session.agent]}</span>
@ -247,11 +326,11 @@ export function CodeChat({
<Button
size="icon"
onClick={() => void handleSend()}
disabled={!draft.trim()}
disabled={!canSend}
title="Send"
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
draft.trim()
canSend
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground',
)}

View file

@ -802,6 +802,15 @@ const ipcSchemas = {
path: z.string().nullable(),
}),
},
'dialog:openFiles': {
req: z.object({
defaultPath: z.string().optional(),
title: z.string().optional(),
}),
res: z.object({
paths: z.array(z.string()),
}),
},
// Knowledge version history channels
'knowledge:history': {
req: z.object({ path: RelPath }),