diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index fad02bfd..0415c617 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -3,6 +3,7 @@ */ import { io, type Socket } from 'socket.io-client' +import { stat } from 'node:fs/promises' import { logger } from '@/ui/logger' import { configuration } from '@/configuration' import type { DaemonState, Machine, MachineMetadata, Update, UpdateMachineBody } from './types' @@ -51,6 +52,14 @@ type MachineRpcHandlers = { requestShutdown: () => void } +interface PathExistsRequest { + paths: string[] +} + +interface PathExistsResponse { + exists: Record +} + export class ApiMachineClient { private socket!: Socket private keepAliveInterval: NodeJS.Timeout | null = null @@ -66,6 +75,25 @@ export class ApiMachineClient { }) registerCommonHandlers(this.rpcHandlerManager, process.cwd()) + + this.rpcHandlerManager.registerHandler('path-exists', async (params) => { + const rawPaths = Array.isArray(params?.paths) ? params.paths : [] + const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string'))) + const exists: Record = {} + + await Promise.all(uniquePaths.map(async (path) => { + const trimmed = path.trim() + if (!trimmed) return + try { + const stats = await stat(trimmed) + exists[trimmed] = stats.isDirectory() + } catch { + exists[trimmed] = false + } + })) + + return { exists } + }) } setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 6e4ec49d..aa4c7c9f 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -133,16 +133,8 @@ export type RpcReadFileResponse = { error?: string } -export type SlashCommand = { - name: string - description?: string - source: 'builtin' | 'user' -} - -export type RpcSlashCommandsResponse = { - success: boolean - commands?: SlashCommand[] - error?: string +export type RpcPathExistsResponse = { + exists: Record } export type SyncEventType = @@ -697,6 +689,24 @@ export class SyncEngine { } } + async checkPathsExist(machineId: string, paths: string[]): Promise> { + const result = await this.machineRpc(machineId, 'path-exists', { paths }) as RpcPathExistsResponse | unknown + if (!result || typeof result !== 'object') { + throw new Error('Unexpected path-exists result') + } + + const existsValue = (result as RpcPathExistsResponse).exists + if (!existsValue || typeof existsValue !== 'object') { + throw new Error('Unexpected path-exists result') + } + + const exists: Record = {} + for (const [key, value] of Object.entries(existsValue)) { + exists[key] = value === true + } + return exists + } + async getGitStatus(sessionId: string, cwd?: string): Promise { return await this.sessionRpc(sessionId, 'git-status', { cwd }) as RpcCommandResponse } @@ -717,10 +727,6 @@ export class SyncEngine { return await this.sessionRpc(sessionId, 'ripgrep', { args, cwd }) as RpcCommandResponse } - async listSlashCommands(sessionId: string, agent: string): Promise { - return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as RpcSlashCommandsResponse - } - private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/server/src/web/routes/machines.ts b/server/src/web/routes/machines.ts index bed2ba62..9043d2de 100644 --- a/server/src/web/routes/machines.ts +++ b/server/src/web/routes/machines.ts @@ -11,6 +11,10 @@ const spawnBodySchema = z.object({ worktreeName: z.string().optional() }) +const pathsExistsSchema = z.object({ + paths: z.array(z.string().min(1)).max(1000) +}) + export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -53,5 +57,36 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json(result) }) + app.post('/machines/:id/paths/exists', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = engine.getMachine(machineId) + if (!machine) { + return c.json({ error: 'Machine not found' }, 404) + } + + const body = await c.req.json().catch(() => null) + const parsed = pathsExistsSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const uniquePaths = Array.from(new Set(parsed.data.paths.map((path) => path.trim()).filter(Boolean))) + if (uniquePaths.length === 0) { + return c.json({ exists: {} }) + } + + try { + const exists = await engine.checkPathsExist(machineId, uniquePaths) + return c.json({ exists }) + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : 'Failed to check paths' }, 500) + } + }) + return app } diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 025ab66e..f6c96337 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -7,6 +7,7 @@ import { requireSessionFromParam, requireSyncEngine } from './guards' type SessionSummaryMetadata = { name?: string path: string + machineId?: string summary?: { text: string } flavor?: string | null worktree?: { @@ -35,6 +36,7 @@ function toSessionSummary(session: Session): SessionSummary { const metadata: SessionSummaryMetadata | null = session.metadata ? { name: session.metadata.name, path: session.metadata.path, + machineId: session.metadata.machineId ?? undefined, summary: session.metadata.summary ? { text: session.metadata.summary.text } : undefined, flavor: session.metadata.flavor ?? null, worktree: session.metadata.worktree diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 595f5d24..c9b88da6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,6 +3,7 @@ import type { FileReadResponse, FileSearchResponse, GitCommandResponse, + MachinePathsExistsResponse, MachinesResponse, MessagesResponse, SlashCommandsResponse, @@ -228,6 +229,19 @@ export class ApiClient { return await this.request('/api/machines') } + async checkMachinePathsExists( + machineId: string, + paths: string[] + ): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/paths/exists`, + { + method: 'POST', + body: JSON.stringify({ paths }) + } + ) + } + async spawnSession( machineId: string, directory: string, diff --git a/web/src/components/NewSession.tsx b/web/src/components/NewSession.tsx index 2ec16595..d294f4d0 100644 --- a/web/src/components/NewSession.tsx +++ b/web/src/components/NewSession.tsx @@ -1,10 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import type { ApiClient } from '@/api/client' import type { Machine } from '@/types/api' +import { Autocomplete } from '@/components/ChatInput/Autocomplete' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/Spinner' import { usePlatform } from '@/hooks/usePlatform' import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' +import { useSessions } from '@/hooks/queries/useSessions' +import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' +import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' import { useRecentPaths } from '@/hooks/useRecentPaths' type AgentType = 'claude' | 'codex' | 'gemini' @@ -25,11 +30,15 @@ export function NewSession(props: { }) { const { haptic } = usePlatform() const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) + const { sessions } = useSessions(props.api) const isFormDisabled = isPending || props.isLoading const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() const [machineId, setMachineId] = useState(null) const [directory, setDirectory] = useState('') + const [suppressSuggestions, setSuppressSuggestions] = useState(false) + const [isDirectoryFocused, setIsDirectoryFocused] = useState(false) + const [pathExistence, setPathExistence] = useState>({}) const [agent, setAgent] = useState('claude') const [yoloMode, setYoloMode] = useState(false) const [sessionType, setSessionType] = useState('simple') @@ -71,6 +80,61 @@ export function NewSession(props: { [getRecentPaths, machineId] ) + const allPaths = useDirectorySuggestions(machineId, sessions, recentPaths) + + const pathsToCheck = useMemo( + () => Array.from(new Set(allPaths)).slice(0, 1000), + [allPaths] + ) + + useEffect(() => { + let cancelled = false + + if (!machineId || pathsToCheck.length === 0) { + setPathExistence({}) + return () => { cancelled = true } + } + + void props.api.checkMachinePathsExists(machineId, pathsToCheck) + .then((result) => { + if (cancelled) return + setPathExistence(result.exists ?? {}) + }) + .catch(() => { + if (cancelled) return + setPathExistence({}) + }) + + return () => { + cancelled = true + } + }, [machineId, pathsToCheck, props.api]) + + const verifiedPaths = useMemo( + () => allPaths.filter((path) => pathExistence[path]), + [allPaths, pathExistence] + ) + + const getSuggestions = useCallback(async (query: string): Promise => { + const lowered = query.toLowerCase() + return verifiedPaths + .filter((path) => path.toLowerCase().includes(lowered)) + .slice(0, 8) + .map((path) => ({ + key: path, + text: path, + label: path + })) + }, [verifiedPaths]) + + const activeQuery = (!isDirectoryFocused || suppressSuggestions) ? null : directory + + const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( + activeQuery, + getSuggestions, + { allowEmptyQuery: true, autoSelectFirst: false } + ) + const handleMachineChange = useCallback((newMachineId: string) => { setMachineId(newMachineId) // Auto-fill most recent path for the new machine @@ -86,6 +150,54 @@ export function NewSession(props: { setDirectory(path) }, []) + const handleSuggestionSelect = useCallback((index: number) => { + const suggestion = suggestions[index] + if (suggestion) { + setDirectory(suggestion.text) + clearSuggestions() + setSuppressSuggestions(true) + } + }, [suggestions, clearSuggestions]) + + const handleDirectoryChange = useCallback((value: string) => { + setSuppressSuggestions(false) + setDirectory(value) + }, []) + + const handleDirectoryFocus = useCallback(() => { + setSuppressSuggestions(false) + setIsDirectoryFocused(true) + }, []) + + const handleDirectoryBlur = useCallback(() => { + setIsDirectoryFocused(false) + }, []) + + const handleDirectoryKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (suggestions.length === 0) return + + if (event.key === 'ArrowUp') { + event.preventDefault() + moveUp() + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + moveDown() + } + + if (event.key === 'Enter' || event.key === 'Tab') { + if (selectedIndex >= 0) { + event.preventDefault() + handleSuggestionSelect(selectedIndex) + } + } + + if (event.key === 'Escape') { + clearSuggestions() + } + }, [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect]) + async function handleCreate() { if (!machineId || !directory.trim()) return @@ -152,14 +264,30 @@ export function NewSession(props: { - setDirectory(e.target.value)} - disabled={isFormDisabled} - className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50" - /> +
+ handleDirectoryChange(event.target.value)} + onKeyDown={handleDirectoryKeyDown} + onFocus={handleDirectoryFocus} + onBlur={handleDirectoryBlur} + disabled={isFormDisabled} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50" + /> + {suggestions.length > 0 && ( +
+ + + +
+ )} +
{/* Recent Paths */} {recentPaths.length > 0 && ( diff --git a/web/src/hooks/useActiveSuggestions.ts b/web/src/hooks/useActiveSuggestions.ts index 60eb33d3..c5dcb521 100644 --- a/web/src/hooks/useActiveSuggestions.ts +++ b/web/src/hooks/useActiveSuggestions.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' export interface Suggestion { key: string @@ -11,6 +11,7 @@ interface SuggestionOptions { clampSelection?: boolean // If true, clamp instead of preserving exact position autoSelectFirst?: boolean // If true, automatically select first item when suggestions appear wrapAround?: boolean // If true, wrap around when reaching top/bottom + allowEmptyQuery?: boolean // If true, allow empty string queries } /** @@ -71,7 +72,8 @@ export function useActiveSuggestions( const { clampSelection = true, autoSelectFirst = true, - wrapAround = true + wrapAround = true, + allowEmptyQuery = false } = options // State for suggestions @@ -129,11 +131,13 @@ export function useActiveSuggestions( const handlerRef = useRef(handler) handlerRef.current = handler - const sync = useMemo(() => { - return new ValueSync(async (query) => { - if (!query) return + const syncRef = useRef | null>(null) - const suggestions = await handlerRef.current(query) + useEffect(() => { + const sync = new ValueSync(async (nextQuery) => { + if (nextQuery === null || (!allowEmptyQuery && nextQuery === '')) return + + const suggestions = await handlerRef.current(nextQuery) setState((prev) => { if (clampSelection) { @@ -174,18 +178,23 @@ export function useActiveSuggestions( } }) }) - }, [clampSelection, autoSelectFirst]) + + syncRef.current = sync + + return () => { + sync.stop() + if (syncRef.current === sync) { + syncRef.current = null + } + } + }, [clampSelection, autoSelectFirst, allowEmptyQuery]) useEffect(() => { - sync.setValue(query) - }, [query, sync]) - - useEffect(() => { - return () => sync.stop() - }, [sync]) + syncRef.current?.setValue(query) + }, [query, handler, clampSelection, autoSelectFirst, allowEmptyQuery]) // If no query return empty suggestions - if (!query) { + if (query === null || (!allowEmptyQuery && query === '')) { return [[], -1, moveUp, moveDown, clear] as const } diff --git a/web/src/hooks/useDirectorySuggestions.ts b/web/src/hooks/useDirectorySuggestions.ts new file mode 100644 index 00000000..8aee8844 --- /dev/null +++ b/web/src/hooks/useDirectorySuggestions.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import type { SessionSummary } from '@/types/api' + +export function useDirectorySuggestions( + machineId: string | null, + sessions: SessionSummary[], + recentPaths: string[] +): string[] { + return useMemo(() => { + const machineSessions = machineId + ? sessions.filter((session) => session.metadata?.machineId === machineId) + : sessions + + const sessionPaths = machineSessions + .map((session) => session.metadata?.path) + .filter((path): path is string => Boolean(path)) + + const worktreePaths = machineSessions + .map((session) => session.metadata?.worktree?.basePath) + .filter((path): path is string => Boolean(path)) + + const dedupedRecent = [...new Set(recentPaths)] + const recentSet = new Set(dedupedRecent) + + const otherPaths = [...new Set([...sessionPaths, ...worktreePaths])] + .filter((path) => !recentSet.has(path)) + .sort((a, b) => a.localeCompare(b)) + + return [...dedupedRecent, ...otherPaths] + }, [machineId, sessions, recentPaths]) +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 8e43eec4..b1af1ed6 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -70,6 +70,7 @@ export type Session = { export type SessionSummaryMetadata = { name?: string path: string + machineId?: string summary?: { text: string } flavor?: string | null worktree?: WorktreeMetadata @@ -132,6 +133,7 @@ export type MessagesResponse = { } export type MachinesResponse = { machines: Machine[] } +export type MachinePathsExistsResponse = { exists: Record } export type SpawnResponse = | { type: 'success'; sessionId: string }