Files
CodeNomad/src/components/prompt-input.tsx
Shantur Rathore fb9d3a08eb Improve file picker UX: inline chips, focus preservation, escape behavior
- Move attachment chips inside textarea wrapper (appears inline)
- Keep textarea focused when pressing Escape (no focus stealing)
- Track ignored @ positions after Escape to prevent re-triggering picker
- Clear ignored positions on message send
- Use capture phase for Escape key handling (true flag on addEventListener)
- Escape now works like email: type @example.com naturally after dismissing picker
- Chips show above textarea input with better spacing (pt-2 pb-1)
2025-10-24 00:27:36 +01:00

304 lines
9.7 KiB
TypeScript

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, attachments: Attachment[]) => Promise<void>
disabled?: boolean
agent: string
model: { providerId: string; modelId: string }
onAgentChange: (agent: string) => Promise<void>
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
}
export default function PromptInput(props: PromptInputProps) {
const [prompt, setPrompt] = createSignal("")
const [sending, setSending] = createSignal(false)
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)
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set())
let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
const attachments = () => getAttachments(props.instanceId, props.sessionId)
onMount(async () => {
const loaded = await getHistory(props.instanceFolder)
setHistory(loaded)
})
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey && !showFilePicker()) {
e.preventDefault()
handleSend()
return
}
const textarea = textareaRef
if (!textarea) return
const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
const currentHistory = history()
if (e.key === "ArrowUp" && !showFilePicker() && atStart && currentHistory.length > 0) {
e.preventDefault()
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1)
setHistoryIndex(newIndex)
setPrompt(currentHistory[newIndex])
setTimeout(() => {
textarea.style.height = "auto"
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"
}, 0)
return
}
if (e.key === "ArrowDown" && !showFilePicker() && historyIndex() >= 0) {
e.preventDefault()
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
setHistoryIndex(newIndex)
setPrompt(currentHistory[newIndex])
} else {
setHistoryIndex(-1)
setPrompt("")
}
setTimeout(() => {
textarea.style.height = "auto"
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"
}, 0)
return
}
}
async function handleSend() {
const text = prompt().trim()
const currentAttachments = attachments()
if (!text || sending() || props.disabled) return
setSending(true)
try {
await addToHistory(props.instanceFolder, text)
const updated = await getHistory(props.instanceFolder)
setHistory(updated)
setHistoryIndex(-1)
await props.onSend(text, currentAttachments)
setPrompt("")
clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>())
if (textareaRef) {
textareaRef.style.height = "auto"
}
} catch (error) {
console.error("Failed to send message:", error)
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
} finally {
setSending(false)
textareaRef?.focus()
}
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
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 textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
if (lastAtIndex !== -1 && !ignoredAtPositions().has(lastAtIndex)) {
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
setAtPosition(lastAtIndex)
setFileSearchQuery(textAfterAt)
setShowFilePicker(true)
return
}
}
setShowFilePicker(false)
setAtPosition(null)
}
function handleFileSelect(path: string) {
const filename = path.split("/").pop() || path
const attachment = createFileAttachment(path, filename)
addAttachment(props.instanceId, props.sessionId, attachment)
const currentPrompt = prompt()
const pos = atPosition()
const cursorPos = textareaRef?.selectionStart || 0
if (pos !== null) {
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + " " + after
setPrompt(newPrompt)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = pos + 1
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
setShowFilePicker(false)
setAtPosition(null)
setFileSearchQuery("")
textareaRef?.focus()
}
function handleFilePickerClose() {
const pos = atPosition()
if (pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowFilePicker(false)
setAtPosition(null)
setFileSearchQuery("")
setTimeout(() => textareaRef?.focus(), 0)
}
function handleFilePickerNavigate(_direction: "up" | "down") {}
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
ref={containerRef}
class={`prompt-input-wrapper relative ${isDragging() ? "border-2 border-blue-500 bg-blue-50 dark:bg-blue-900/10" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Show when={showFilePicker() && instance()}>
<FilePicker
open={showFilePicker()}
onClose={handleFilePickerClose}
onSelect={handleFileSelect}
onNavigate={handleFilePickerNavigate}
instanceClient={instance()!.client}
searchQuery={fileSearchQuery()}
textareaRef={textareaRef}
/>
</Show>
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-1 px-3 pt-2 pb-1">
<For each={attachments()}>
{(att) => <AttachmentChip attachment={att} onRemove={() => handleRemoveAttachment(att.id)} />}
</For>
</div>
</Show>
<textarea
ref={textareaRef}
class="prompt-input"
placeholder="Type your message, @file, or /command..."
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={sending() || props.disabled}
rows={1}
/>
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
<Show when={sending()} fallback={<span class="send-icon"></span>}>
<span class="spinner-small" />
</Show>
</button>
</div>
<div class="prompt-input-hints">
<HintRow>
<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
instanceId={props.instanceId}
sessionId={props.sessionId}
currentAgent={props.agent}
onAgentChange={props.onAgentChange}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentModel={props.model}
onModelChange={props.onModelChange}
/>
</div>
</div>
</div>
)
}