feat: add directory autocomplete with validation for new session form

This commit is contained in:
weishu
2025-12-28 16:31:53 +08:00
parent ce40de900f
commit 05deb6fff8
9 changed files with 292 additions and 37 deletions
+28
View File
@@ -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<string, boolean>
}
export class ApiMachineClient {
private socket!: Socket<ServerToDaemonEvents, DaemonToServerEvents>
private keepAliveInterval: NodeJS.Timeout | null = null
@@ -66,6 +75,25 @@ export class ApiMachineClient {
})
registerCommonHandlers(this.rpcHandlerManager, process.cwd())
this.rpcHandlerManager.registerHandler<PathExistsRequest, PathExistsResponse>('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<string, boolean> = {}
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 {
+20 -14
View File
@@ -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<string, boolean>
}
export type SyncEventType =
@@ -697,6 +689,24 @@ export class SyncEngine {
}
}
async checkPathsExist(machineId: string, paths: string[]): Promise<Record<string, boolean>> {
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<string, boolean> = {}
for (const [key, value] of Object.entries(existsValue)) {
exists[key] = value === true
}
return exists
}
async getGitStatus(sessionId: string, cwd?: string): Promise<RpcCommandResponse> {
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<RpcSlashCommandsResponse> {
return await this.sessionRpc(sessionId, 'listSlashCommands', { agent }) as RpcSlashCommandsResponse
}
private async sessionRpc(sessionId: string, method: string, params: unknown): Promise<unknown> {
return await this.rpcCall(`${sessionId}:${method}`, params)
}
+35
View File
@@ -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<WebAppEnv> {
const app = new Hono<WebAppEnv>()
@@ -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
}
+2
View File
@@ -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
+14
View File
@@ -3,6 +3,7 @@ import type {
FileReadResponse,
FileSearchResponse,
GitCommandResponse,
MachinePathsExistsResponse,
MachinesResponse,
MessagesResponse,
SlashCommandsResponse,
@@ -228,6 +229,19 @@ export class ApiClient {
return await this.request<MachinesResponse>('/api/machines')
}
async checkMachinePathsExists(
machineId: string,
paths: string[]
): Promise<MachinePathsExistsResponse> {
return await this.request<MachinePathsExistsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/paths/exists`,
{
method: 'POST',
body: JSON.stringify({ paths })
}
)
}
async spawnSession(
machineId: string,
directory: string,
+137 -9
View File
@@ -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<string | null>(null)
const [directory, setDirectory] = useState('')
const [suppressSuggestions, setSuppressSuggestions] = useState(false)
const [isDirectoryFocused, setIsDirectoryFocused] = useState(false)
const [pathExistence, setPathExistence] = useState<Record<string, boolean>>({})
const [agent, setAgent] = useState<AgentType>('claude')
const [yoloMode, setYoloMode] = useState(false)
const [sessionType, setSessionType] = useState<SessionType>('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<Suggestion[]> => {
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<HTMLInputElement>) => {
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: {
<label className="text-xs font-medium text-[var(--app-hint)]">
Directory
</label>
<input
type="text"
placeholder="/path/to/project"
value={directory}
onChange={(e) => 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"
/>
<div className="relative">
<input
type="text"
placeholder="/path/to/project"
value={directory}
onChange={(event) => 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 && (
<div className="absolute top-full left-0 right-0 z-10 mt-1">
<FloatingOverlay maxHeight={200}>
<Autocomplete
suggestions={suggestions}
selectedIndex={selectedIndex}
onSelect={handleSuggestionSelect}
/>
</FloatingOverlay>
</div>
)}
</div>
{/* Recent Paths */}
{recentPaths.length > 0 && (
+23 -14
View File
@@ -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<string | null>(async (query) => {
if (!query) return
const syncRef = useRef<ValueSync<string | null> | null>(null)
const suggestions = await handlerRef.current(query)
useEffect(() => {
const sync = new ValueSync<string | null>(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
}
+31
View File
@@ -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])
}
+2
View File
@@ -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<string, boolean> }
export type SpawnResponse =
| { type: 'success'; sessionId: string }