Refactor to unified picker showing both agents and files

This commit is contained in:
Shantur Rathore
2025-10-24 13:09:49 +01:00
parent d17e8e56c8
commit 3c73a6bc4a
4 changed files with 382 additions and 286 deletions

View File

@@ -1,148 +0,0 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session"
interface AgentPickerProps {
open: boolean
onSelect: (agentName: string) => void
onClose: () => void
agents: Agent[]
searchQuery: string
textareaRef?: HTMLTextAreaElement
}
const AgentPicker: Component<AgentPickerProps> = (props) => {
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
let containerRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined
createEffect(() => {
if (!props.open) return
const query = props.searchQuery.toLowerCase()
const filtered = query
? props.agents.filter(
(agent) =>
agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)),
)
: props.agents
setFilteredAgents(filtered)
setSelectedIndex(0)
setTimeout(() => {
if (scrollContainerRef) {
scrollContainerRef.scrollTop = 0
}
}, 0)
})
function scrollToSelected() {
setTimeout(() => {
const selectedElement = containerRef?.querySelector('[data-agent-selected="true"]')
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
}, 0)
}
function handleSelect(agentName: string) {
props.onSelect(agentName)
}
function handleKeyDown(e: KeyboardEvent) {
if (!props.open) return
const agents = filteredAgents()
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, agents.length - 1))
scrollToSelected()
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
scrollToSelected()
} else if (e.key === "Enter") {
e.preventDefault()
const selected = agents[selectedIndex()]
if (selected) {
handleSelect(selected.name)
}
} else if (e.key === "Escape") {
e.preventDefault()
props.onClose()
}
}
createEffect(() => {
if (props.open) {
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
})
}
})
return (
<Show when={props.open}>
<div
ref={containerRef}
class="absolute bottom-full left-0 mb-1 w-full max-w-md rounded-md border border-gray-300 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800 z-50"
>
<div class="border-b border-gray-200 px-3 py-2 dark:border-gray-700">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Select Agent</div>
</div>
<div ref={scrollContainerRef} class="max-h-60 overflow-y-auto">
<Show when={filteredAgents().length === 0}>
<div class="px-3 py-4 text-center text-sm text-gray-500 dark:text-gray-400">No agents found</div>
</Show>
<For each={filteredAgents()}>
{(agent, index) => (
<div
class={`cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 ${
index() === selectedIndex() ? "bg-blue-50 dark:bg-blue-900/20" : ""
}`}
data-agent-selected={index() === selectedIndex()}
onClick={() => handleSelect(agent.name)}
>
<div class="flex items-start gap-2">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{agent.name}</span>
<Show when={agent.mode === "subagent"}>
<span class="rounded bg-blue-50 px-1.5 py-0.5 text-xs font-normal text-blue-600 dark:bg-blue-500/20 dark:text-blue-400">
subagent
</span>
</Show>
</div>
<Show when={agent.description}>
<div class="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
{agent.description && agent.description.length > 80
? agent.description.slice(0, 80) + "..."
: agent.description}
</div>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
<div class="border-t border-gray-200 px-3 py-2 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">
<span class="font-medium"></span> navigate <span class="font-medium">Enter</span> select {" "}
<span class="font-medium">Esc</span> close
</div>
</div>
</div>
</Show>
)
}
export default AgentPicker

View File

@@ -1,8 +1,7 @@
import { createSignal, Show, onMount, For, onCleanup } from "solid-js"
import AgentSelector from "./agent-selector"
import ModelSelector from "./model-selector"
import FilePicker from "./file-picker"
import AgentPicker from "./agent-picker"
import UnifiedPicker from "./unified-picker"
import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
@@ -30,10 +29,8 @@ 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 [showAgentPicker, setShowAgentPicker] = createSignal(false)
const [fileSearchQuery, setFileSearchQuery] = createSignal("")
const [agentSearchQuery, setAgentSearchQuery] = createSignal("")
const [showPicker, setShowPicker] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [isDragging, setIsDragging] = createSignal(false)
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set())
@@ -253,7 +250,7 @@ export default function PromptInput(props: PromptInputProps) {
}
}
if (e.key === "Enter" && !e.shiftKey && !showFilePicker() && !showAgentPicker()) {
if (e.key === "Enter" && !e.shiftKey && !showPicker()) {
e.preventDefault()
handleSend()
return
@@ -262,7 +259,7 @@ export default function PromptInput(props: PromptInputProps) {
const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
const currentHistory = history()
if (e.key === "ArrowUp" && !showFilePicker() && !showAgentPicker() && atStart && currentHistory.length > 0) {
if (e.key === "ArrowUp" && !showPicker() && atStart && currentHistory.length > 0) {
e.preventDefault()
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1)
setHistoryIndex(newIndex)
@@ -274,7 +271,7 @@ export default function PromptInput(props: PromptInputProps) {
return
}
if (e.key === "ArrowDown" && !showFilePicker() && !showAgentPicker() && historyIndex() >= 0) {
if (e.key === "ArrowDown" && !showPicker() && historyIndex() >= 0) {
e.preventDefault()
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
@@ -355,157 +352,125 @@ export default function PromptInput(props: PromptInputProps) {
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
if (!ignoredAtPositions().has(lastAtIndex)) {
setAtPosition(lastAtIndex)
const availableAgents = instanceAgents()
const matchesAgent = availableAgents.some((agent) =>
agent.name.toLowerCase().includes(textAfterAt.toLowerCase()),
)
if (matchesAgent && textAfterAt.length > 0) {
setAgentSearchQuery(textAfterAt)
setShowAgentPicker(true)
setShowFilePicker(false)
} else {
setFileSearchQuery(textAfterAt)
setShowFilePicker(true)
setShowAgentPicker(false)
}
setSearchQuery(textAfterAt)
setShowPicker(true)
}
return
}
}
setShowFilePicker(false)
setShowAgentPicker(false)
setShowPicker(false)
setAtPosition(null)
}
function handleFileSelect(path: string) {
const isFolder = path.endsWith("/")
const filename = path.split("/").pop() || path
function handlePickerSelect(item: { type: "agent"; agent: any } | { type: "file"; file: any }) {
if (item.type === "agent") {
const agentName = item.agent.name
const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "agent" && att.source.name === agentName,
)
if (!alreadyAttached) {
const attachment = createAgentAttachment(agentName)
addAttachment(props.instanceId, props.sessionId, attachment)
}
if (isFolder) {
const currentPrompt = prompt()
const pos = atPosition()
const cursorPos = textareaRef?.selectionStart || 0
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + path + after
const attachmentText = `@${agentName}`
const newPrompt = before + attachmentText + " " + after
setPrompt(newPrompt)
setFileSearchQuery(path)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = pos + 1 + path.length
const newCursorPos = pos + attachmentText.length + 1
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.style.height = "auto"
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px"
textareaRef.focus()
}
}, 0)
}
} else if (item.type === "file") {
const path = item.file.path
const isFolder = path.endsWith("/")
const filename = path.split("/").pop() || path
return
}
if (isFolder) {
const currentPrompt = prompt()
const pos = atPosition()
const cursorPos = textareaRef?.selectionStart || 0
const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some((att) => att.source.type === "file" && att.source.path === path)
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + path + after
setPrompt(newPrompt)
setSearchQuery(path)
if (!alreadyAttached) {
const attachment = createFileAttachment(path, filename, "text/plain", undefined, props.instanceFolder)
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 attachmentText = `@${filename}`
const newPrompt = before + attachmentText + " " + after
setPrompt(newPrompt)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = pos + attachmentText.length + 1
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.style.height = "auto"
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px"
setTimeout(() => {
if (textareaRef) {
const newCursorPos = pos + 1 + path.length
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.style.height = "auto"
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px"
textareaRef.focus()
}
}, 0)
}
}, 0)
return
}
const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some((att) => att.source.type === "file" && att.source.path === path)
if (!alreadyAttached) {
const attachment = createFileAttachment(path, filename, "text/plain", undefined, props.instanceFolder)
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 attachmentText = `@${filename}`
const newPrompt = before + attachmentText + " " + after
setPrompt(newPrompt)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = pos + attachmentText.length + 1
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.style.height = "auto"
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px"
}
}, 0)
}
}
setShowFilePicker(false)
setShowPicker(false)
setAtPosition(null)
setFileSearchQuery("")
setSearchQuery("")
textareaRef?.focus()
}
function handleFilePickerClose() {
function handlePickerClose() {
const pos = atPosition()
if (pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowFilePicker(false)
setShowPicker(false)
setAtPosition(null)
setFileSearchQuery("")
setTimeout(() => textareaRef?.focus(), 0)
}
function handleFilePickerNavigate(_direction: "up" | "down") {}
function handleAgentSelect(agentName: string) {
const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "agent" && att.source.name === agentName,
)
if (!alreadyAttached) {
const attachment = createAgentAttachment(agentName)
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 attachmentText = `@${agentName}`
const newPrompt = before + attachmentText + " " + after
setPrompt(newPrompt)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = pos + attachmentText.length + 1
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.style.height = "auto"
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px"
}
}, 0)
}
setShowAgentPicker(false)
setAtPosition(null)
setAgentSearchQuery("")
textareaRef?.focus()
}
function handleAgentPickerClose() {
const pos = atPosition()
if (pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowAgentPicker(false)
setAtPosition(null)
setAgentSearchQuery("")
setSearchQuery("")
setTimeout(() => textareaRef?.focus(), 0)
}
@@ -555,30 +520,19 @@ export default function PromptInput(props: PromptInputProps) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Show when={showFilePicker() && instance()}>
<FilePicker
open={showFilePicker()}
onClose={handleFilePickerClose}
onSelect={handleFileSelect}
onNavigate={handleFilePickerNavigate}
<Show when={showPicker() && instance()}>
<UnifiedPicker
open={showPicker()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
agents={instanceAgents()}
instanceClient={instance()!.client}
searchQuery={fileSearchQuery()}
searchQuery={searchQuery()}
textareaRef={textareaRef}
workspaceFolder={props.instanceFolder}
/>
</Show>
<Show when={showAgentPicker()}>
<AgentPicker
open={showAgentPicker()}
onClose={handleAgentPickerClose}
onSelect={handleAgentSelect}
agents={instanceAgents()}
searchQuery={agentSearchQuery()}
textareaRef={textareaRef}
/>
</Show>
<div class="flex flex-1 flex-col">
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-1.5 border-b border-gray-200 pb-2 dark:border-gray-700">

View File

@@ -0,0 +1,290 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session"
interface FileItem {
path: string
added?: number
removed?: number
isGitFile: boolean
}
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
interface UnifiedPickerProps {
open: boolean
onSelect: (item: PickerItem) => void
onClose: () => void
agents: Agent[]
instanceClient: any
searchQuery: string
textareaRef?: HTMLTextAreaElement
workspaceFolder: string
}
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const [files, setFiles] = createSignal<FileItem[]>([])
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [loading, setLoading] = createSignal(false)
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
const [isInitialized, setIsInitialized] = createSignal(false)
let containerRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined
async function fetchFiles(searchQuery: string) {
setLoading(true)
try {
if (allFiles().length === 0) {
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
path,
isGitFile: false,
}))
setAllFiles(scannedFiles)
}
const filteredFiles = searchQuery.trim()
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
: allFiles()
setFiles(filteredFiles)
setSelectedIndex(0)
setTimeout(() => {
if (scrollContainerRef) {
scrollContainerRef.scrollTop = 0
}
}, 0)
} catch (error) {
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
setFiles([])
} finally {
setLoading(false)
}
}
let lastQuery = ""
createEffect(() => {
if (props.open && !isInitialized()) {
setIsInitialized(true)
fetchFiles(props.searchQuery)
lastQuery = props.searchQuery
return
}
if (props.open && props.searchQuery !== lastQuery) {
lastQuery = props.searchQuery
fetchFiles(props.searchQuery)
}
})
createEffect(() => {
if (!props.open) return
const query = props.searchQuery.toLowerCase()
const filtered = query
? props.agents.filter(
(agent) =>
agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)),
)
: props.agents
setFilteredAgents(filtered)
})
const allItems = (): PickerItem[] => {
const items: PickerItem[] = []
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
files().forEach((file) => items.push({ type: "file", file }))
return items
}
function scrollToSelected() {
setTimeout(() => {
const selectedElement = containerRef?.querySelector('[data-picker-selected="true"]')
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
}, 0)
}
function handleSelect(item: PickerItem) {
props.onSelect(item)
}
function handleKeyDown(e: KeyboardEvent) {
if (!props.open) return
const items = allItems()
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
scrollToSelected()
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
scrollToSelected()
} else if (e.key === "Enter") {
e.preventDefault()
const selected = items[selectedIndex()]
if (selected) {
handleSelect(selected)
}
} else if (e.key === "Escape") {
e.preventDefault()
props.onClose()
}
}
createEffect(() => {
if (props.open) {
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
})
}
})
const agentCount = () => filteredAgents().length
const fileCount = () => files().length
return (
<Show when={props.open}>
<div
ref={containerRef}
class="absolute bottom-full left-0 mb-1 w-full max-w-md rounded-md border border-gray-300 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800 z-50"
>
<div class="border-b border-gray-200 px-3 py-2 dark:border-gray-700">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
Select Agent or File
<Show when={loading()}>
<span class="ml-2">Loading...</span>
</Show>
</div>
</div>
<div ref={scrollContainerRef} class="max-h-60 overflow-y-auto">
<Show when={agentCount() === 0 && fileCount() === 0}>
<div class="px-3 py-4 text-center text-sm text-gray-500 dark:text-gray-400">No results found</div>
</Show>
<Show when={agentCount() > 0}>
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50">
AGENTS
</div>
<For each={filteredAgents()}>
{(agent) => {
const itemIndex = allItems().findIndex(
(item) => item.type === "agent" && item.agent.name === agent.name,
)
return (
<div
class={`cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 ${
itemIndex === selectedIndex() ? "bg-blue-50 dark:bg-blue-900/20" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "agent", agent })}
>
<div class="flex items-start gap-2">
<svg
class="h-4 w-4 mt-0.5 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{agent.name}</span>
<Show when={agent.mode === "subagent"}>
<span class="rounded bg-blue-50 px-1.5 py-0.5 text-xs font-normal text-blue-600 dark:bg-blue-500/20 dark:text-blue-400">
subagent
</span>
</Show>
</div>
<Show when={agent.description}>
<div class="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
{agent.description && agent.description.length > 80
? agent.description.slice(0, 80) + "..."
: agent.description}
</div>
</Show>
</div>
</div>
</div>
)
}}
</For>
</Show>
<Show when={fileCount() > 0}>
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50">
FILES
</div>
<For each={files()}>
{(file) => {
const itemIndex = allItems().findIndex((item) => item.type === "file" && item.file.path === file.path)
const isFolder = file.path.endsWith("/")
return (
<div
class={`cursor-pointer px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 ${
itemIndex === selectedIndex() ? "bg-blue-50 dark:bg-blue-900/20" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "file", file })}
>
<div class="flex items-center gap-2 text-sm">
<Show
when={isFolder}
fallback={
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
>
<svg class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
</Show>
<span class="text-gray-900 dark:text-gray-100 truncate">{file.path}</span>
</div>
</div>
)
}}
</For>
</Show>
</div>
<div class="border-t border-gray-200 px-3 py-2 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">
<span class="font-medium"></span> navigate <span class="font-medium">Enter</span> select {" "}
<span class="font-medium">Esc</span> close
</div>
</div>
</div>
</Show>
)
}
export default UnifiedPicker