diff --git a/src/App.tsx b/src/App.tsx index 48d4e035..917b248b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Component, onMount, onCleanup, Show, createMemo, createEffect } from "solid-js" import type { Session } from "./types/session" +import type { Attachment } from "./types/attachment" import EmptyState from "./components/empty-state" import SessionPicker from "./components/session-picker" import CommandPalette from "./components/command-palette" @@ -70,8 +71,8 @@ const SessionView: Component<{ } }) - async function handleSendMessage(prompt: string) { - await sendMessage(props.instanceId, props.sessionId, prompt) + async function handleSendMessage(prompt: string, attachments: Attachment[]) { + await sendMessage(props.instanceId, props.sessionId, prompt, attachments) } async function handleAgentChange(agent: string) { diff --git a/src/components/attachment-chip.tsx b/src/components/attachment-chip.tsx new file mode 100644 index 00000000..52c77c86 --- /dev/null +++ b/src/components/attachment-chip.tsx @@ -0,0 +1,27 @@ +import { Component } from "solid-js" +import type { Attachment } from "../types/attachment" + +interface AttachmentChipProps { + attachment: Attachment + onRemove: () => void +} + +const AttachmentChip: Component = (props) => { + return ( +
+ {props.attachment.display} + +
+ ) +} + +export default AttachmentChip diff --git a/src/components/file-picker.tsx b/src/components/file-picker.tsx new file mode 100644 index 00000000..7c5605ff --- /dev/null +++ b/src/components/file-picker.tsx @@ -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 = (props) => { + const [files, setFiles] = createSignal([]) + 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 ( + !open && props.onClose()}> + + +
+ +
+ handleQueryChange(e.currentTarget.value)} + class="w-full border-0 bg-transparent text-base outline-none placeholder:text-gray-400 dark:placeholder:text-gray-500" + /> +
+ +
+ +
+ Loading files... +
+ } + > + 0} + fallback={
No matching files
} + > + + {(file, index) => ( +
handleSelect(file.path)} + > +
+ {file.path} + +
+ + +{file.added} + + + -{file.removed} + +
+
+
+
+ )} +
+
+ +
+ +
+
+ ↑↓ Navigate • Enter Select • Esc Close +
+
+ + +
+
+ ) +} + +export default FilePicker diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index 66e485d7..57f3b024 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -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 + onSend: (prompt: string, attachments: Attachment[]) => Promise disabled?: boolean agent: string model: { providerId: string; modelId: string } @@ -24,7 +30,14 @@ export default function PromptInput(props: PromptInputProps) { const [history, setHistory] = createSignal([]) const [historyIndex, setHistoryIndex] = createSignal(-1) const [isFocused, setIsFocused] = createSignal(false) + const [showFilePicker, setShowFilePicker] = createSignal(false) + const [fileSearchQuery, setFileSearchQuery] = createSignal("") + const [atPosition, setAtPosition] = createSignal(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 (
-
+ 0}> +
+ + {(att) => handleRemoveAttachment(att.id)} />} + +
+
+