Add file attachments with @ mentions and drag & drop support

- Create attachment type system with file, text, symbol, and agent sources
- Implement file picker with SDK integration (find.files, file.status)
- Add @ detection in prompt input to trigger file search
- Create attachment chips UI for managing attached files
- Add attachment state management per session
- Update message submission to include attachments
- Implement drag & drop support for adding files
- Show git-modified files first in file picker with +/- indicators
- Filter files as user types after @
- Clear attachments after successful message send
This commit is contained in:
Shantur Rathore
2025-10-23 22:46:29 +01:00
parent 9dfb3cd612
commit bdd9837538
7 changed files with 536 additions and 18 deletions

View File

@@ -0,0 +1,27 @@
import { Component } from "solid-js"
import type { Attachment } from "../types/attachment"
interface AttachmentChipProps {
attachment: Attachment
onRemove: () => void
}
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
return (
<div
class="inline-flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
title={props.attachment.source.type === "file" ? props.attachment.source.path : undefined}
>
<span class="font-mono">{props.attachment.display}</span>
<button
onClick={props.onRemove}
class="flex h-4 w-4 items-center justify-center rounded hover:bg-blue-200 dark:hover:bg-blue-800"
aria-label="Remove attachment"
>
×
</button>
</div>
)
}
export default AttachmentChip

View File

@@ -0,0 +1,208 @@
import { Component, createSignal, createEffect, For, Show, onMount } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
interface FileItem {
path: string
added?: number
removed?: number
isGitFile: boolean
}
interface FilePickerProps {
open: boolean
onClose: () => void
onSelect: (path: string) => void
instanceId: string
instanceClient: any
searchQuery?: string
}
const FilePicker: Component<FilePickerProps> = (props) => {
const [files, setFiles] = createSignal<FileItem[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [query, setQuery] = createSignal(props.searchQuery || "")
const [loading, setLoading] = createSignal(false)
let inputRef: HTMLInputElement | undefined
async function fetchFiles(searchQuery: string) {
if (!props.instanceClient) return
setLoading(true)
try {
const gitFilesPromise = props.instanceClient.file.status()
const searchFilesPromise = searchQuery
? props.instanceClient.find.files({ query: { query: searchQuery } })
: Promise.resolve({ data: [] })
const [gitResponse, searchResponse] = await Promise.all([gitFilesPromise, searchFilesPromise])
const gitFiles: FileItem[] = (gitResponse.data || []).map((file: any) => ({
path: file.path,
added: file.added,
removed: file.removed,
isGitFile: true,
}))
const searchFiles: FileItem[] = (searchResponse.data || [])
.filter((path: string) => !gitFiles.some((gf) => gf.path === path))
.map((path: string) => ({
path,
isGitFile: false,
}))
const allFiles = searchQuery
? [...gitFiles.filter((f) => f.path.includes(searchQuery)), ...searchFiles]
: gitFiles
setFiles(allFiles)
setSelectedIndex(0)
} catch (error) {
console.error("Failed to fetch files:", error)
setFiles([])
} finally {
setLoading(false)
}
}
createEffect(() => {
if (props.open) {
fetchFiles(query())
}
})
createEffect(() => {
if (props.searchQuery !== undefined) {
setQuery(props.searchQuery)
}
})
onMount(() => {
if (props.open && inputRef) {
setTimeout(() => inputRef?.focus(), 50)
}
})
function handleKeyDown(e: KeyboardEvent) {
const fileList = files()
if (fileList.length === 0) return
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, fileList.length - 1))
scrollToSelected()
break
case "ArrowUp":
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
scrollToSelected()
break
case "Enter":
e.preventDefault()
if (fileList[selectedIndex()]) {
handleSelect(fileList[selectedIndex()].path)
}
break
case "Escape":
e.preventDefault()
props.onClose()
break
}
}
function scrollToSelected() {
setTimeout(() => {
const selectedElement = document.querySelector('[data-file-selected="true"]')
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
}, 0)
}
function handleSelect(path: string) {
props.onSelect(path)
props.onClose()
}
function handleQueryChange(value: string) {
setQuery(value)
fetchFiles(value)
}
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/50" />
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<Dialog.Content
class="w-full max-w-2xl rounded-lg border border-gray-300 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onKeyDown={handleKeyDown}
>
<div class="border-b border-gray-200 p-4 dark:border-gray-700">
<input
ref={inputRef}
type="text"
placeholder="Search files..."
value={query()}
onInput={(e) => handleQueryChange(e.currentTarget.value)}
class="w-full border-0 bg-transparent text-base outline-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div class="max-h-96 overflow-y-auto">
<Show
when={!loading()}
fallback={
<div class="p-8 text-center text-sm text-gray-500">
<div class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
<span class="ml-2">Loading files...</span>
</div>
}
>
<Show
when={files().length > 0}
fallback={<div class="p-8 text-center text-sm text-gray-500">No matching files</div>}
>
<For each={files()}>
{(file, index) => (
<div
data-file-selected={index() === selectedIndex()}
class={`cursor-pointer border-b border-gray-100 px-4 py-2 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800 ${
index() === selectedIndex() ? "bg-blue-50 dark:bg-blue-900/20" : ""
}`}
onClick={() => handleSelect(file.path)}
>
<div class="flex items-center justify-between">
<span class="font-mono text-sm text-gray-900 dark:text-gray-100">{file.path}</span>
<Show when={file.isGitFile && (file.added || file.removed)}>
<div class="flex gap-2 text-xs">
<Show when={file.added}>
<span class="text-green-600 dark:text-green-400">+{file.added}</span>
</Show>
<Show when={file.removed}>
<span class="text-red-600 dark:text-red-400">-{file.removed}</span>
</Show>
</div>
</Show>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
<div class="border-t border-gray-200 p-2 text-xs text-gray-500 dark:border-gray-700">
<div class="flex items-center justify-between px-2">
<span> Navigate Enter Select Esc Close</span>
</div>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default FilePicker

View File

@@ -1,16 +1,22 @@
import { createSignal, Show, onMount, createEffect } from "solid-js"
import { createSignal, Show, onMount, createEffect, For } from "solid-js"
import AgentSelector from "./agent-selector"
import ModelSelector from "./model-selector"
import FilePicker from "./file-picker"
import AttachmentChip from "./attachment-chip"
import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, removeAttachment, clearAttachments } from "../stores/attachments"
import { createFileAttachment } from "../types/attachment"
import type { Attachment } from "../types/attachment"
import Kbd from "./kbd"
import HintRow from "./hint-row"
import { isMac } from "../lib/keyboard-utils"
import { getActiveInstance } from "../stores/instances"
interface PromptInputProps {
instanceId: string
instanceFolder: string
sessionId: string
onSend: (prompt: string) => Promise<void>
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
disabled?: boolean
agent: string
model: { providerId: string; modelId: string }
@@ -24,7 +30,14 @@ export default function PromptInput(props: PromptInputProps) {
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [isFocused, setIsFocused] = createSignal(false)
const [showFilePicker, setShowFilePicker] = createSignal(false)
const [fileSearchQuery, setFileSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [isDragging, setIsDragging] = createSignal(false)
let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
const attachments = () => getAttachments(props.instanceId, props.sessionId)
onMount(async () => {
const loaded = await getHistory(props.instanceFolder)
@@ -32,6 +45,10 @@ export default function PromptInput(props: PromptInputProps) {
})
function handleKeyDown(e: KeyboardEvent) {
if (showFilePicker()) {
return
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
@@ -76,6 +93,7 @@ export default function PromptInput(props: PromptInputProps) {
async function handleSend() {
const text = prompt().trim()
const currentAttachments = attachments()
if (!text || sending() || props.disabled) return
setSending(true)
@@ -86,8 +104,9 @@ export default function PromptInput(props: PromptInputProps) {
setHistory(updated)
setHistoryIndex(-1)
await props.onSend(text)
await props.onSend(text, currentAttachments)
setPrompt("")
clearAttachments(props.instanceId, props.sessionId)
if (textareaRef) {
textareaRef.style.height = "auto"
@@ -103,22 +122,116 @@ export default function PromptInput(props: PromptInputProps) {
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
setPrompt(target.value)
const value = target.value
setPrompt(value)
setHistoryIndex(-1)
target.style.height = "auto"
target.style.height = Math.min(target.scrollHeight, 200) + "px"
const cursorPos = target.selectionStart
const lastAtIndex = value.lastIndexOf("@", cursorPos)
if (lastAtIndex !== -1 && lastAtIndex < cursorPos) {
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
if (!hasSpace) {
setAtPosition(lastAtIndex)
setFileSearchQuery(textAfterAt)
setShowFilePicker(true)
} else {
setShowFilePicker(false)
}
} else {
setShowFilePicker(false)
}
}
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
function handleFileSelect(path: string) {
const instance = getActiveInstance()
if (!instance) return
const filename = path.split("/").pop() || path
const attachment = createFileAttachment(path, filename)
addAttachment(props.instanceId, props.sessionId, attachment)
const currentPrompt = prompt()
const pos = atPosition()
if (pos !== null) {
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(textareaRef?.selectionStart || pos)
setPrompt(before + after)
}
setShowFilePicker(false)
setAtPosition(null)
setFileSearchQuery("")
setTimeout(() => textareaRef?.focus(), 50)
}
function handleRemoveAttachment(attachmentId: string) {
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
for (let i = 0; i < files.length; i++) {
const file = files[i]
const path = (file as any).path || file.name
const filename = file.name
const mime = file.type || "text/plain"
const attachment = createFileAttachment(path, filename, mime)
addAttachment(props.instanceId, props.sessionId, attachment)
}
textareaRef?.focus()
}
const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !sending() && !props.disabled
const instance = () => getActiveInstance()
return (
<div class="prompt-input-container">
<div class="prompt-input-wrapper">
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-2 border-b border-gray-200 p-2 dark:border-gray-700">
<For each={attachments()}>
{(att) => <AttachmentChip attachment={att} onRemove={() => handleRemoveAttachment(att.id)} />}
</For>
</div>
</Show>
<div
ref={containerRef}
class={`prompt-input-wrapper ${isDragging() ? "border-2 border-blue-500 bg-blue-50 dark:bg-blue-900/10" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<textarea
ref={textareaRef}
class="prompt-input"
placeholder="Type your message or /command..."
placeholder="Type your message, @file, or /command..."
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
@@ -135,8 +248,8 @@ export default function PromptInput(props: PromptInputProps) {
</div>
<div class="prompt-input-hints">
<HintRow>
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd></Kbd> for history {" "}
<Kbd shortcut="cmd+p" /> to focus
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd>@</Kbd> for files <Kbd></Kbd> for
history
</HintRow>
<div class="flex items-center gap-2">
<AgentSelector
@@ -153,6 +266,21 @@ export default function PromptInput(props: PromptInputProps) {
/>
</div>
</div>
<Show when={showFilePicker() && instance()}>
<FilePicker
open={showFilePicker()}
onClose={() => {
setShowFilePicker(false)
setAtPosition(null)
setFileSearchQuery("")
}}
onSelect={handleFileSelect}
instanceId={props.instanceId}
instanceClient={instance()!.client}
searchQuery={fileSearchQuery()}
/>
</Show>
</div>
)
}