diff --git a/src/components/agent-picker.tsx b/src/components/agent-picker.tsx deleted file mode 100644 index 239ae6a9..00000000 --- a/src/components/agent-picker.tsx +++ /dev/null @@ -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 = (props) => { - const [filteredAgents, setFilteredAgents] = createSignal([]) - 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 ( - -
-
-
Select Agent
-
- -
- -
No agents found
-
- - - {(agent, index) => ( -
handleSelect(agent.name)} - > -
-
-
- {agent.name} - - - subagent - - -
- -
- {agent.description && agent.description.length > 80 - ? agent.description.slice(0, 80) + "..." - : agent.description} -
-
-
-
-
- )} -
-
- -
-
- ↑↓ navigate • Enter select •{" "} - Esc close -
-
-
-
- ) -} - -export default AgentPicker diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index f6122b50..dbfa5456 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -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([]) 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(null) const [isDragging, setIsDragging] = createSignal(false) const [ignoredAtPositions, setIgnoredAtPositions] = createSignal>(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} > - - + - - - -
0}>
diff --git a/src/components/unified-picker.tsx b/src/components/unified-picker.tsx new file mode 100644 index 00000000..6285ff4e --- /dev/null +++ b/src/components/unified-picker.tsx @@ -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 = (props) => { + const [files, setFiles] = createSignal([]) + const [filteredAgents, setFilteredAgents] = createSignal([]) + const [selectedIndex, setSelectedIndex] = createSignal(0) + const [loading, setLoading] = createSignal(false) + const [allFiles, setAllFiles] = createSignal([]) + 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 ( + +
+
+
+ Select Agent or File + + Loading... + +
+
+ +
+ +
No results found
+
+ + 0}> +
+ AGENTS +
+ + {(agent) => { + const itemIndex = allItems().findIndex( + (item) => item.type === "agent" && item.agent.name === agent.name, + ) + return ( +
handleSelect({ type: "agent", agent })} + > +
+ + + +
+
+ {agent.name} + + + subagent + + +
+ +
+ {agent.description && agent.description.length > 80 + ? agent.description.slice(0, 80) + "..." + : agent.description} +
+
+
+
+
+ ) + }} +
+
+ + 0}> +
+ FILES +
+ + {(file) => { + const itemIndex = allItems().findIndex((item) => item.type === "file" && item.file.path === file.path) + const isFolder = file.path.endsWith("/") + return ( +
handleSelect({ type: "file", file })} + > +
+ + + + } + > + + + + + {file.path} +
+
+ ) + }} +
+
+
+ +
+
+ ↑↓ navigate • Enter select •{" "} + Esc close +
+
+
+
+ ) +} + +export default UnifiedPicker diff --git a/tasks/todo/024-agent-attachments.md b/tasks/done/024-agent-attachments.md similarity index 100% rename from tasks/todo/024-agent-attachments.md rename to tasks/done/024-agent-attachments.md