refactor(web): unify session creation into single page with path history

This commit is contained in:
weishu
2025-12-22 21:45:11 +08:00
parent 4290f539bd
commit 999ee64852
5 changed files with 323 additions and 74 deletions
+1 -6
View File
@@ -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),
+231
View File
@@ -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>
)
}
+2 -7
View File
@@ -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
}
+71
View File
@@ -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
View File
@@ -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']