diff --git a/web/src/App.tsx b/web/src/App.tsx index fc6cdd90..fb29653b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -83,9 +83,7 @@ export function App() { }, [goBack, pathname]) const queryClient = useQueryClient() const sessionMatch = matchRoute({ to: '/sessions/$sessionId' }) - const spawnMatch = matchRoute({ to: '/machines/$machineId/spawn' }) const selectedSessionId = sessionMatch ? sessionMatch.sessionId : null - const spawnMachineId = spawnMatch ? spawnMatch.machineId : null const handleSseConnect = useCallback(() => { void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) @@ -101,11 +99,8 @@ export function App() { if (selectedSessionId) { return { sessionId: selectedSessionId } } - if (spawnMachineId) { - return { machineId: spawnMachineId } - } return { all: true } - }, [selectedSessionId, spawnMachineId]) + }, [selectedSessionId]) useSSE({ enabled: Boolean(api && token), diff --git a/web/src/components/NewSession.tsx b/web/src/components/NewSession.tsx new file mode 100644 index 00000000..64d8c288 --- /dev/null +++ b/web/src/components/NewSession.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ApiClient } from '@/api/client' +import type { Machine } from '@/types/api' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { usePlatform } from '@/hooks/usePlatform' +import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' +import { useRecentPaths } from '@/hooks/useRecentPaths' + +type AgentType = 'claude' | 'codex' | 'gemini' + +function getMachineTitle(machine: Machine): string { + if (machine.metadata?.displayName) return machine.metadata.displayName + if (machine.metadata?.host) return machine.metadata.host + return machine.id.slice(0, 8) +} + +export function NewSession(props: { + api: ApiClient + machines: Machine[] + isLoading?: boolean + onSuccess: (sessionId: string) => void + onCancel: () => void +}) { + const { haptic } = usePlatform() + const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) + const isFormDisabled = isPending || props.isLoading + const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() + + const [machineId, setMachineId] = useState(null) + const [directory, setDirectory] = useState('') + const [agent, setAgent] = useState('claude') + const [error, setError] = useState(null) + + // Initialize with last used machine or first available + useEffect(() => { + if (props.machines.length === 0) return + if (machineId && props.machines.find((m) => m.id === machineId)) return + + const lastUsed = getLastUsedMachineId() + const foundLast = lastUsed ? props.machines.find((m) => m.id === lastUsed) : null + + if (foundLast) { + setMachineId(foundLast.id) + const paths = getRecentPaths(foundLast.id) + if (paths[0]) setDirectory(paths[0]) + } else if (props.machines[0]) { + setMachineId(props.machines[0].id) + } + }, [props.machines, machineId, getLastUsedMachineId, getRecentPaths]) + + const selectedMachine = useMemo( + () => props.machines.find((m) => m.id === machineId) ?? null, + [props.machines, machineId] + ) + + const recentPaths = useMemo( + () => getRecentPaths(machineId), + [getRecentPaths, machineId] + ) + + const handleMachineChange = useCallback((newMachineId: string) => { + setMachineId(newMachineId) + // Auto-fill most recent path for the new machine + const paths = getRecentPaths(newMachineId) + if (paths[0]) { + setDirectory(paths[0]) + } else { + setDirectory('') + } + }, [getRecentPaths]) + + const handlePathClick = useCallback((path: string) => { + setDirectory(path) + }, []) + + async function handleCreate() { + if (!machineId || !directory.trim()) return + + setError(null) + try { + const result = await spawnSession({ + machineId, + directory: directory.trim(), + agent, + }) + + if (result.type === 'success') { + haptic.notification('success') + // Save for next time + setLastUsedMachineId(machineId) + addRecentPath(machineId, directory.trim()) + props.onSuccess(result.sessionId) + return + } + + haptic.notification('error') + setError(result.message) + } catch (e) { + haptic.notification('error') + setError(e instanceof Error ? e.message : 'Failed to create session') + } + } + + const canCreate = machineId && directory.trim() && !isFormDisabled + + return ( +
+ + + Create Session + + +
+ {/* Machine Selector */} +
+ + +
+ + {/* Directory Input */} +
+ + 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" + /> + + {/* Recent Paths */} + {recentPaths.length > 0 && ( +
+ Recent: +
+ {recentPaths.map((path) => ( + + ))} +
+
+ )} +
+ + {/* Agent Selector */} +
+ +
+ {(['claude', 'codex', 'gemini'] as const).map((agentType) => ( + + ))} +
+
+ + {/* Error Message */} + {(error ?? spawnError) ? ( +
+ {error ?? spawnError} +
+ ) : null} + + {/* Action Buttons */} +
+ + +
+
+
+
+
+ ) +} diff --git a/web/src/hooks/useAppGoBack.ts b/web/src/hooks/useAppGoBack.ts index ba95bbc2..fca267a9 100644 --- a/web/src/hooks/useAppGoBack.ts +++ b/web/src/hooks/useAppGoBack.ts @@ -8,17 +8,12 @@ export function useAppGoBack(): () => void { return useCallback(() => { // Use explicit path navigation for consistent behavior across all environments - if (pathname.startsWith('/sessions/')) { + if (pathname === '/sessions/new') { navigate({ to: '/sessions' }) return } - if (pathname.endsWith('/spawn')) { - navigate({ to: '/machines' }) - return - } - - if (pathname.startsWith('/machines')) { + if (pathname.startsWith('/sessions/')) { navigate({ to: '/sessions' }) return } diff --git a/web/src/hooks/useRecentPaths.ts b/web/src/hooks/useRecentPaths.ts new file mode 100644 index 00000000..d7f51e7a --- /dev/null +++ b/web/src/hooks/useRecentPaths.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo, useState } from 'react' + +const STORAGE_KEY = 'hapi:recentPaths' +const MAX_PATHS_PER_MACHINE = 5 + +type RecentPathsData = Record + +function loadRecentPaths(): RecentPathsData { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return stored ? JSON.parse(stored) : {} + } catch { + return {} + } +} + +function saveRecentPaths(data: RecentPathsData): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) + } catch { + // Ignore storage errors + } +} + +export function useRecentPaths() { + const [data, setData] = useState(loadRecentPaths) + + const getRecentPaths = useCallback((machineId: string | null): string[] => { + if (!machineId) return [] + return data[machineId] ?? [] + }, [data]) + + const addRecentPath = useCallback((machineId: string, path: string): void => { + const trimmed = path.trim() + if (!trimmed) return + + setData((prev) => { + const existing = prev[machineId] ?? [] + // Remove if already exists, then add to front + const filtered = existing.filter((p) => p !== trimmed) + const updated = [trimmed, ...filtered].slice(0, MAX_PATHS_PER_MACHINE) + + const newData = { ...prev, [machineId]: updated } + saveRecentPaths(newData) + return newData + }) + }, []) + + const getLastUsedMachineId = useCallback((): string | null => { + try { + return localStorage.getItem('hapi:lastMachineId') + } catch { + return null + } + }, []) + + const setLastUsedMachineId = useCallback((machineId: string): void => { + try { + localStorage.setItem('hapi:lastMachineId', machineId) + } catch { + // Ignore storage errors + } + }, []) + + return useMemo(() => ({ + getRecentPaths, + addRecentPath, + getLastUsedMachineId, + setLastUsedMachineId, + }), [getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId]) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 100b4b8d..9220cc70 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -11,8 +11,7 @@ import { import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' -import { MachineList } from '@/components/MachineList' -import { SpawnSession } from '@/components/SpawnSession' +import { NewSession } from '@/components/NewSession' import { useAppContext } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' import { isTelegramApp } from '@/hooks/useTelegram' @@ -62,7 +61,7 @@ function SessionsPage() { to: '/sessions/$sessionId', params: { sessionId }, })} - onNewSession={() => navigate({ to: '/machines' })} + onNewSession={() => navigate({ to: '/sessions/new' })} onRefresh={handleRefresh} isLoading={isLoading} /> @@ -127,56 +126,15 @@ function SessionPage() { ) } -function MachinesPage() { +function NewSessionPage() { const { api } = useAppContext() const navigate = useNavigate() const goBack = useAppGoBack() - const { machines, error: machinesError } = useMachines(api, true) - - return ( -
-
- {!isTelegramApp() && ( - - )} -
Machines
-
- - {machinesError ? ( -
- {machinesError} -
- ) : null} - - navigate({ - to: '/machines/$machineId/spawn', - params: { machineId }, - })} - /> -
- ) -} - -function SpawnPage() { - const { api } = useAppContext() - const { machineId } = useParams({ from: '/machines/$machineId/spawn' }) - const { machines } = useMachines(api, true) - const navigate = useNavigate() - const goBack = useAppGoBack() const queryClient = useQueryClient() - - const machineForSpawn = machines.find((machine) => machine.id === machineId) ?? null + const { machines, isLoading: machinesLoading, error: machinesError } = useMachines(api, true) const handleCancel = useCallback(() => { - navigate({ to: '/machines' }) + navigate({ to: '/sessions' }) }, [navigate]) const handleSuccess = useCallback((sessionId: string) => { @@ -207,10 +165,16 @@ function SpawnPage() {
Create Session
- + {machinesError} + + ) : null} + + @@ -267,16 +231,10 @@ const sessionFileRoute = createRoute({ component: FilePage, }) -const machinesRoute = createRoute({ +const newSessionRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/machines', - component: MachinesPage, -}) - -const spawnRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/machines/$machineId/spawn', - component: SpawnPage, + path: '/sessions/new', + component: NewSessionPage, }) export const routeTree = rootRoute.addChildren([ @@ -285,8 +243,7 @@ export const routeTree = rootRoute.addChildren([ sessionRoute, sessionFilesRoute, sessionFileRoute, - machinesRoute, - spawnRoute, + newSessionRoute, ]) type RouterHistory = Parameters[0]['history']