mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor(web): unify session creation into single page with path history
This commit is contained in:
+1
-6
@@ -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),
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [directory, setDirectory] = useState('')
|
||||
const [agent, setAgent] = useState<AgentType>('claude')
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="p-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Create Session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Machine Selector */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
Machine
|
||||
</label>
|
||||
<select
|
||||
value={machineId ?? ''}
|
||||
onChange={(e) => handleMachineChange(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"
|
||||
>
|
||||
{props.isLoading && (
|
||||
<option value="">Loading machines...</option>
|
||||
)}
|
||||
{!props.isLoading && props.machines.length === 0 && (
|
||||
<option value="">No machines available</option>
|
||||
)}
|
||||
{props.machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{getMachineTitle(m)}
|
||||
{m.metadata?.platform ? ` (${m.metadata.platform})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Directory Input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* Recent Paths */}
|
||||
{recentPaths.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<span className="text-xs text-[var(--app-hint)]">Recent:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{recentPaths.map((path) => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
onClick={() => handlePathClick(path)}
|
||||
disabled={isFormDisabled}
|
||||
className="rounded bg-[var(--app-subtle-bg)] px-2 py-1 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] transition-colors truncate max-w-[200px] disabled:opacity-50"
|
||||
title={path}
|
||||
>
|
||||
{path}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent Selector */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
Agent
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(['claude', 'codex', 'gemini'] as const).map((agentType) => (
|
||||
<label
|
||||
key={agentType}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="agent"
|
||||
value={agentType}
|
||||
checked={agent === agentType}
|
||||
onChange={() => setAgent(agentType)}
|
||||
disabled={isFormDisabled}
|
||||
className="accent-[var(--app-link)]"
|
||||
/>
|
||||
<span className="text-sm capitalize">{agentType}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{(error ?? spawnError) ? (
|
||||
<div className="text-sm text-red-600">
|
||||
{error ?? spawnError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={props.onCancel}
|
||||
disabled={isFormDisabled}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
{isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'hapi:recentPaths'
|
||||
const MAX_PATHS_PER_MACHINE = 5
|
||||
|
||||
type RecentPathsData = Record<string, string[]>
|
||||
|
||||
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<RecentPathsData>(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])
|
||||
}
|
||||
+18
-61
@@ -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 (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--app-border)] bg-[var(--app-bg)] p-3 pt-[calc(0.75rem+env(safe-area-inset-top))]">
|
||||
{!isTelegramApp() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 font-semibold">Machines</div>
|
||||
</div>
|
||||
|
||||
{machinesError ? (
|
||||
<div className="p-3 text-sm text-red-600">
|
||||
{machinesError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MachineList
|
||||
machines={machines}
|
||||
onSelect={(machineId) => navigate({
|
||||
to: '/machines/$machineId/spawn',
|
||||
params: { machineId },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
<div className="flex-1 font-semibold">Create Session</div>
|
||||
</div>
|
||||
|
||||
<SpawnSession
|
||||
{machinesError ? (
|
||||
<div className="p-3 text-sm text-red-600">
|
||||
{machinesError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NewSession
|
||||
api={api}
|
||||
machineId={machineId}
|
||||
machine={machineForSpawn}
|
||||
machines={machines}
|
||||
isLoading={machinesLoading}
|
||||
onCancel={handleCancel}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
@@ -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<typeof createRouter>[0]['history']
|
||||
|
||||
Reference in New Issue
Block a user