mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add directory autocomplete with validation for new session form
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user