import { Component, createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js" import type { Agent } from "../types/session" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { serverApi } from "../lib/api-client" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" const log = getLogger("actions") const SEARCH_RESULT_LIMIT = 100 const SEARCH_DEBOUNCE_MS = 200 type LoadingState = "idle" | "listing" | "search" interface FileItem { path: string relativePath: string added?: number removed?: number isGitFile: boolean isDirectory: boolean } function formatDisplayPath(basePath: string, isDirectory: boolean) { if (!isDirectory) { return basePath } const trimmed = basePath.replace(/\/+$/, "") return trimmed.length > 0 ? `${trimmed}/` : "./" } function isRootPath(value: string) { return value === "." || value === "./" || value === "/" } function normalizeRelativePath(basePath: string, isDirectory: boolean) { if (isRootPath(basePath)) { return "." } const withoutPrefix = basePath.replace(/^\.\/+/, "") if (isDirectory) { const trimmed = withoutPrefix.replace(/\/+$/, "") return trimmed || "." } return withoutPrefix } function normalizeQuery(rawQuery: string) { const trimmed = rawQuery.trim() if (!trimmed) { return "" } // Don't normalize "." - it's used for workspace root return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "") } function mapEntriesToFileItems(entries: { path: string; type: "file" | "directory" }[]): FileItem[] { return entries.map((entry) => { const isDirectory = entry.type === "directory" return { path: formatDisplayPath(entry.path, isDirectory), relativePath: normalizeRelativePath(entry.path, isDirectory), isDirectory, isGitFile: false, } }) } type PickerItem = | { type: "agent"; agent: Agent } | { type: "file"; file: FileItem } | { type: "command"; command: SDKCommand } export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter" interface UnifiedPickerProps { open: boolean mode?: "mention" | "command" onSelect: (item: PickerItem, action: PickerSelectAction) => void onClose: () => void agents: Agent[] commands?: SDKCommand[] instanceClient: OpencodeClient | null searchQuery: string textareaRef?: HTMLTextAreaElement workspaceId: string } const UnifiedPicker: Component = (props) => { const { t } = useI18n() const mode = () => props.mode ?? "mention" const [files, setFiles] = createSignal([]) const [filteredAgents, setFilteredAgents] = createSignal([]) const [selectedIndex, setSelectedIndex] = createSignal(0) const [loadingState, setLoadingState] = createSignal("idle") const [allFiles, setAllFiles] = createSignal([]) const [isInitialized, setIsInitialized] = createSignal(false) const [cachedWorkspaceId, setCachedWorkspaceId] = createSignal(null) let containerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined let lastWorkspaceId: string | null = null let lastQuery = "" let lastCommandQuery = "" let inflightWorkspaceId: string | null = null let inflightSnapshotPromise: Promise | null = null let activeRequestId = 0 let queryDebounceTimer: ReturnType | null = null function resetScrollPosition() { setTimeout(() => { if (scrollContainerRef) { scrollContainerRef.scrollTop = 0 } }, 0) } function applyFileResults(nextFiles: FileItem[]) { setFiles(nextFiles) setSelectedIndex(0) resetScrollPosition() } async function fetchWorkspaceSnapshot(workspaceId: string): Promise { if (inflightWorkspaceId === workspaceId && inflightSnapshotPromise) { return inflightSnapshotPromise } inflightWorkspaceId = workspaceId inflightSnapshotPromise = serverApi .listWorkspaceFiles(workspaceId) .then((entries) => mapEntriesToFileItems(entries)) .then((snapshot) => { setAllFiles(snapshot) setCachedWorkspaceId(workspaceId) return snapshot }) .catch((error) => { log.error(`[UnifiedPicker] Failed to load workspace files:`, error) setAllFiles([]) setCachedWorkspaceId(null) throw error }) .finally(() => { if (inflightWorkspaceId === workspaceId) { inflightWorkspaceId = null inflightSnapshotPromise = null } }) return inflightSnapshotPromise } async function ensureWorkspaceSnapshot(workspaceId: string) { if (cachedWorkspaceId() === workspaceId && allFiles().length > 0) { return allFiles() } return fetchWorkspaceSnapshot(workspaceId) } async function loadFilesForQuery(rawQuery: string, workspaceId: string) { const normalizedQuery = normalizeQuery(rawQuery) const requestId = ++activeRequestId const hasCachedSnapshot = !normalizedQuery && cachedWorkspaceId() === workspaceId && allFiles().length > 0 const mode: LoadingState = normalizedQuery ? "search" : hasCachedSnapshot ? "idle" : "listing" if (mode !== "idle") { setLoadingState(mode) } else { setLoadingState("idle") } try { if (!normalizedQuery) { const snapshot = await ensureWorkspaceSnapshot(workspaceId) if (!shouldApplyResults(requestId, workspaceId)) { return } applyFileResults(snapshot) return } const results = await serverApi.searchWorkspaceFiles(workspaceId, normalizedQuery, { limit: SEARCH_RESULT_LIMIT, }) if (!shouldApplyResults(requestId, workspaceId)) { return } applyFileResults(mapEntriesToFileItems(results)) } catch (error) { if (workspaceId === props.workspaceId) { log.error(`[UnifiedPicker] Failed to fetch files:`, error) if (shouldApplyResults(requestId, workspaceId)) { applyFileResults([]) } } } finally { if (shouldFinalizeRequest(requestId, workspaceId)) { setLoadingState("idle") } } } function clearQueryDebounce() { if (queryDebounceTimer) { clearTimeout(queryDebounceTimer) queryDebounceTimer = null } } function scheduleLoadFilesForQuery(rawQuery: string, workspaceId: string, immediate = false) { clearQueryDebounce() const normalizedQuery = normalizeQuery(rawQuery) const shouldDebounce = !immediate && normalizedQuery.length > 0 if (shouldDebounce) { queryDebounceTimer = setTimeout(() => { queryDebounceTimer = null void loadFilesForQuery(rawQuery, workspaceId) }, SEARCH_DEBOUNCE_MS) return } void loadFilesForQuery(rawQuery, workspaceId) } function shouldApplyResults(requestId: number, workspaceId: string) { return props.open && workspaceId === props.workspaceId && requestId === activeRequestId } function shouldFinalizeRequest(requestId: number, workspaceId: string) { return workspaceId === props.workspaceId && requestId === activeRequestId } function resetPickerState() { clearQueryDebounce() setFiles([]) setAllFiles([]) setCachedWorkspaceId(null) setIsInitialized(false) setSelectedIndex(0) setLoadingState("idle") lastWorkspaceId = null lastQuery = "" lastCommandQuery = "" activeRequestId = 0 } onCleanup(() => { clearQueryDebounce() }) createEffect(() => { if (!props.open) { resetPickerState() return } if (mode() !== "mention") { // Command mode doesn't use file snapshots. return } const workspaceChanged = lastWorkspaceId !== props.workspaceId const queryChanged = lastQuery !== props.searchQuery if (queryChanged) { // Reset selectedIndex to 0 when query changes to avoid ghost state // This ensures proper highlighting when navigating back to root or changing queries setSelectedIndex(0) resetScrollPosition() } if (!isInitialized() || workspaceChanged || queryChanged) { setIsInitialized(true) lastWorkspaceId = props.workspaceId lastQuery = props.searchQuery const shouldSkipDebounce = workspaceChanged || normalizeQuery(props.searchQuery).length === 0 scheduleLoadFilesForQuery(props.searchQuery, props.workspaceId, shouldSkipDebounce) } }) createEffect(() => { if (!props.open) return if (mode() !== "mention") return const query = props.searchQuery.toLowerCase() const visibleAgents = props.agents.filter((agent) => !agent.hidden) const filtered = query ? visibleAgents.filter( (agent) => agent.name.toLowerCase().includes(query) || (agent.description && agent.description.toLowerCase().includes(query)), ) : visibleAgents setFilteredAgents(filtered) }) const filteredCommands = createMemo(() => { if (mode() !== "command") return [] const q = props.searchQuery.trim().toLowerCase() const source = props.commands ?? [] if (!q) return source return source.filter((cmd) => { const nameMatch = cmd.name.toLowerCase().includes(q) const descMatch = (cmd.description ?? "").toLowerCase().includes(q) return nameMatch || descMatch }) }) createEffect(() => { if (!props.open) return if (mode() !== "command") return const query = props.searchQuery const count = filteredCommands().length if (query !== lastCommandQuery) { lastCommandQuery = query setSelectedIndex(0) resetScrollPosition() return } if (count <= 0) { if (selectedIndex() !== 0) { setSelectedIndex(0) } return } const current = selectedIndex() if (current < 0) { setSelectedIndex(0) return } if (current >= count) { setSelectedIndex(count - 1) } }) const allItems = (): PickerItem[] => { const items: PickerItem[] = [] if (mode() === "command") { filteredCommands().forEach((command) => items.push({ type: "command", command })) return items } // Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/") const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./" if (mode() === "mention" && isExactRootQuery) { const rootFile: FileItem = { path: ".", relativePath: ".", isDirectory: true, isGitFile: false, } items.push({ type: "file", file: rootFile }) } // Don't show agents for exact root path queries if (!isExactRootQuery) { 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, "click") } function handleKeyDown(e: KeyboardEvent) { if (!props.open) return const items = allItems() if (e.key === "ArrowDown") { e.preventDefault() e.stopPropagation() setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1)) scrollToSelected() } else if (e.key === "ArrowUp") { e.preventDefault() e.stopPropagation() setSelectedIndex((prev) => Math.max(prev - 1, 0)) scrollToSelected() } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault() e.stopPropagation() const selected = items[selectedIndex()] if (selected) { const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" props.onSelect(selected, action) } } else if (e.key === "Escape") { e.preventDefault() e.stopPropagation() props.onClose() } } createEffect(() => { if (props.open) { document.addEventListener("keydown", handleKeyDown) onCleanup(() => { document.removeEventListener("keydown", handleKeyDown) }) } }) const commandCount = () => filteredCommands().length const agentCount = () => filteredAgents().length const fileCount = () => files().length const isLoading = () => mode() === "mention" && loadingState() !== "idle" const loadingMessage = () => { if (loadingState() === "search") { return t("unifiedPicker.loading.searching") } if (loadingState() === "listing") { return t("unifiedPicker.loading.loadingWorkspace") } return "" } return ( ) } export default UnifiedPicker