feat(web): integrate TanStack React Router for client-side routing

Replace state-based screen navigation with proper URL-based routing. This includes:
- New router configuration with routes for sessions, machines, and spawn pages
- App context provider to share API and token across the app
- useAppGoBack hook for handling browser and Telegram back navigation
- Refactored App component to render outlet and use router hooks
- Memory history for Telegram app, browser history for web
This commit is contained in:
weishu
2025-12-20 16:49:10 +08:00
parent f0493ce68d
commit 3bac5c7bc6
7 changed files with 366 additions and 208 deletions
+22 -204
View File
@@ -1,65 +1,24 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { Outlet, useLocation, useMatchRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
import { getTelegramWebApp } from '@/hooks/useTelegram'
import { initializeTheme } from '@/hooks/useTheme'
import { useAuth } from '@/hooks/useAuth'
import { useAuthSource } from '@/hooks/useAuthSource'
import { useSSE } from '@/hooks/useSSE'
import { queryKeys } from '@/lib/query-keys'
import { useMessages } from '@/hooks/queries/useMessages'
import { useMachines } from '@/hooks/queries/useMachines'
import { useSession } from '@/hooks/queries/useSession'
import { useSessions } from '@/hooks/queries/useSessions'
import { useSendMessage } from '@/hooks/mutations/useSendMessage'
import { SessionList } from '@/components/SessionList'
import { SessionChat } from '@/components/SessionChat'
import { MachineList } from '@/components/MachineList'
import { SpawnSession } from '@/components/SpawnSession'
import { AppContextProvider } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { LoginPrompt } from '@/components/LoginPrompt'
import { InstallPrompt } from '@/components/InstallPrompt'
import { OfflineBanner } from '@/components/OfflineBanner'
type Screen =
| { type: 'sessions' }
| { type: 'session'; sessionId: string }
| { type: 'machines' }
| { type: 'spawn'; machineId: string }
function getStartParam(): string | null {
const query = new URLSearchParams(window.location.search)
const fromQuery = query.get('startapp') || query.get('tgWebAppStartParam')
if (fromQuery) return fromQuery
return getTelegramWebApp()?.initDataUnsafe?.start_param ?? null
}
function getDeepLinkedSessionId(): string | null {
const startParam = getStartParam()
if (startParam?.startsWith('session_')) {
return startParam.slice('session_'.length)
}
return null
}
export function App() {
const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource()
const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource)
const [screen, setScreen] = useState<Screen>(() => {
const deepLinkedSessionId = getDeepLinkedSessionId()
if (deepLinkedSessionId) {
return { type: 'session', sessionId: deepLinkedSessionId }
}
return { type: 'sessions' }
})
// Navigate and sync browser history (browser only, not Telegram)
const navigateTo = useCallback((newScreen: Screen) => {
setScreen(newScreen)
if (!isTelegramApp()) {
history.pushState({ screen: newScreen }, '')
}
}, [])
const goBack = useAppGoBack()
const pathname = useLocation({ select: (location) => location.pathname })
const matchRoute = useMatchRoute()
useEffect(() => {
const tg = getTelegramWebApp()
@@ -104,45 +63,12 @@ export function App() {
}
}, [])
// Handle browser back button (browser only, not Telegram)
useEffect(() => {
if (isTelegramApp()) return
const handlePopState = (event: PopStateEvent) => {
if (event.state?.screen) {
setScreen(event.state.screen)
} else {
setScreen({ type: 'sessions' })
}
}
window.addEventListener('popstate', handlePopState)
history.replaceState({ screen }, '')
return () => window.removeEventListener('popstate', handlePopState)
}, [])
const goBack = useCallback(() => {
// Browser: use native back (triggers popstate)
if (!isTelegramApp()) {
history.back()
return
}
// Telegram: update state directly
setScreen((prev) => {
if (prev.type === 'session') return { type: 'sessions' }
if (prev.type === 'machines') return { type: 'sessions' }
if (prev.type === 'spawn') return { type: 'machines' }
return prev
})
}, [])
useEffect(() => {
const tg = getTelegramWebApp()
const backButton = tg?.BackButton
if (!backButton) return
if (screen.type === 'sessions') {
if (pathname === '/' || pathname === '/sessions') {
backButton.offClick(goBack)
backButton.hide()
return
@@ -154,49 +80,12 @@ export function App() {
backButton.offClick(goBack)
backButton.hide()
}
}, [goBack, screen.type])
}, [goBack, pathname])
const queryClient = useQueryClient()
const selectedSessionId = screen.type === 'session' ? screen.sessionId : null
const machinesEnabled = screen.type === 'machines' || screen.type === 'spawn'
const {
sessions,
isLoading: sessionsLoading,
error: sessionsError,
refetch: refetchSessions,
} = useSessions(api)
const {
session: selectedSession,
refetch: refetchSession,
} = useSession(api, selectedSessionId)
const {
messages,
warning: messagesWarning,
isLoading: messagesLoading,
isLoadingMore: messagesLoadingMore,
hasMore: messagesHasMore,
loadMore: loadMoreMessages,
refetch: refetchMessages,
} = useMessages(api, selectedSessionId)
const {
machines,
error: machinesError,
} = useMachines(api, machinesEnabled)
const {
sendMessage,
retryMessage,
isSending,
} = useSendMessage(api, selectedSessionId)
const refreshSessions = useCallback(() => {
void refetchSessions()
}, [refetchSessions])
const refreshSelectedSession = useCallback(() => {
if (!selectedSessionId) return
void refetchSession()
void refetchMessages()
}, [selectedSessionId, refetchMessages, refetchSession])
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 })
@@ -209,14 +98,14 @@ export function App() {
const handleSseEvent = useCallback(() => {}, [])
const eventSubscription = useMemo(() => {
if (screen.type === 'session') {
return { sessionId: screen.sessionId }
if (selectedSessionId) {
return { sessionId: selectedSessionId }
}
if (screen.type === 'spawn') {
return { machineId: screen.machineId }
if (spawnMachineId) {
return { machineId: spawnMachineId }
}
return { all: true }
}, [screen])
}, [selectedSessionId, spawnMachineId])
useSSE({
enabled: Boolean(api && token),
@@ -275,84 +164,13 @@ export function App() {
)
}
const machineForSpawn = screen.type === 'spawn'
? machines.find(m => m.id === screen.machineId) ?? null
: null
return (
<>
<AppContextProvider value={{ api, token }}>
<OfflineBanner />
<div className="h-full flex flex-col">
{screen.type === 'sessions' ? (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{sessionsError ? <div className="text-sm text-red-600">{sessionsError}</div> : null}
<SessionList
sessions={sessions}
onSelect={(sessionId) => navigateTo({ type: 'session', sessionId })}
onNewSession={() => navigateTo({ type: 'machines' })}
onRefresh={refreshSessions}
isLoading={sessionsLoading}
/>
</div>
) : screen.type === 'session' ? (
selectedSession ? (
<SessionChat
api={api}
session={selectedSession}
messages={messages}
messagesWarning={messagesWarning}
hasMoreMessages={messagesHasMore}
isLoadingMessages={messagesLoading}
isLoadingMoreMessages={messagesLoadingMore}
isSending={isSending}
onBack={goBack}
onRefresh={refreshSelectedSession}
onLoadMore={() => {
void loadMoreMessages()
}}
onSend={sendMessage}
onRetryMessage={retryMessage}
/>
) : (
<div className="p-4 text-sm text-[var(--app-hint)]">Loading session…</div>
)
) : screen.type === 'machines' ? (
<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">
<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) => navigateTo({ type: 'spawn', machineId })}
/>
</div>
) : (
<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">
<div className="flex-1 font-semibold">Create Session</div>
</div>
<SpawnSession
api={api}
machineId={screen.machineId}
machine={machineForSpawn}
onCancel={goBack}
onSuccess={(sessionId) => {
refreshSessions()
navigateTo({ type: 'session', sessionId })
}}
/>
</div>
)}
<Outlet />
</div>
<InstallPrompt />
</>
</AppContextProvider>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { useCallback } from 'react'
import { useLocation, useNavigate, useRouter } from '@tanstack/react-router'
import { isTelegramApp } from '@/hooks/useTelegram'
export function useAppGoBack(): () => void {
const navigate = useNavigate()
const router = useRouter()
const pathname = useLocation({ select: (location) => location.pathname })
return useCallback(() => {
if (!isTelegramApp()) {
router.history.back()
return
}
if (pathname.startsWith('/sessions/')) {
navigate({ to: '/sessions' })
return
}
if (pathname.endsWith('/spawn')) {
navigate({ to: '/machines' })
return
}
if (pathname.startsWith('/machines')) {
navigate({ to: '/sessions' })
}
}, [navigate, pathname, router])
}
+28
View File
@@ -0,0 +1,28 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { ApiClient } from '@/api/client'
type AppContextValue = {
api: ApiClient
token: string
}
const AppContext = createContext<AppContextValue | null>(null)
export function AppContextProvider(props: {
value: AppContextValue
children: ReactNode
}) {
return (
<AppContext.Provider value={props.value}>
{props.children}
</AppContext.Provider>
)
}
export function useAppContext(): AppContextValue {
const context = useContext(AppContext)
if (!context) {
throw new Error('AppContext is not available')
}
return context
}
+32 -4
View File
@@ -2,15 +2,38 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { App } from './App'
import { RouterProvider, createMemoryHistory } from '@tanstack/react-router'
import './index.css'
import { registerSW } from 'virtual:pwa-register'
import { isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram'
import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram'
import { queryClient } from './lib/query-client'
import { createAppRouter } from './router'
function getStartParam(): string | null {
const query = new URLSearchParams(window.location.search)
const fromQuery = query.get('startapp') || query.get('tgWebAppStartParam')
if (fromQuery) return fromQuery
return getTelegramWebApp()?.initDataUnsafe?.start_param ?? null
}
function getDeepLinkedSessionId(): string | null {
const startParam = getStartParam()
if (startParam?.startsWith('session_')) {
return startParam.slice('session_'.length)
}
return null
}
function getInitialPath(): string {
const sessionId = getDeepLinkedSessionId()
return sessionId ? `/sessions/${sessionId}` : '/sessions'
}
async function bootstrap() {
// Only load Telegram SDK in Telegram environment (with 3s timeout)
if (isTelegramEnvironment()) {
const isTelegram = isTelegramEnvironment()
if (isTelegram) {
await loadTelegramSdk()
}
@@ -35,10 +58,15 @@ async function bootstrap() {
}
})
const history = isTelegram
? createMemoryHistory({ initialEntries: [getInitialPath()] })
: undefined
const router = createAppRouter(history)
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<RouterProvider router={router} />
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
</QueryClientProvider>
</React.StrictMode>
+228
View File
@@ -0,0 +1,228 @@
import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Navigate,
createRootRoute,
createRoute,
createRouter,
useNavigate,
useParams,
} from '@tanstack/react-router'
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 { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useMessages } from '@/hooks/queries/useMessages'
import { useMachines } from '@/hooks/queries/useMachines'
import { useSession } from '@/hooks/queries/useSession'
import { useSessions } from '@/hooks/queries/useSessions'
import { useSendMessage } from '@/hooks/mutations/useSendMessage'
import { queryKeys } from '@/lib/query-keys'
function SessionsPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const { sessions, isLoading, error, refetch } = useSessions(api)
const handleRefresh = useCallback(() => {
void refetch()
}, [refetch])
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{error ? <div className="text-sm text-red-600">{error}</div> : null}
<SessionList
sessions={sessions}
onSelect={(sessionId) => navigate({
to: '/sessions/$sessionId',
params: { sessionId },
})}
onNewSession={() => navigate({ to: '/machines' })}
onRefresh={handleRefresh}
isLoading={isLoading}
/>
</div>
)
}
function SessionPage() {
const { api } = useAppContext()
const goBack = useAppGoBack()
const { sessionId } = useParams({ from: '/sessions/$sessionId' })
const {
session,
refetch: refetchSession,
} = useSession(api, sessionId)
const {
messages,
warning: messagesWarning,
isLoading: messagesLoading,
isLoadingMore: messagesLoadingMore,
hasMore: messagesHasMore,
loadMore: loadMoreMessages,
refetch: refetchMessages,
} = useMessages(api, sessionId)
const {
sendMessage,
retryMessage,
isSending,
} = useSendMessage(api, sessionId)
const refreshSelectedSession = useCallback(() => {
void refetchSession()
void refetchMessages()
}, [refetchMessages, refetchSession])
if (!session) {
return (
<div className="p-4 text-sm text-[var(--app-hint)]">
Loading session
</div>
)
}
return (
<SessionChat
api={api}
session={session}
messages={messages}
messagesWarning={messagesWarning}
hasMoreMessages={messagesHasMore}
isLoadingMessages={messagesLoading}
isLoadingMoreMessages={messagesLoadingMore}
isSending={isSending}
onBack={goBack}
onRefresh={refreshSelectedSession}
onLoadMore={() => {
void loadMoreMessages()
}}
onSend={sendMessage}
onRetryMessage={retryMessage}
/>
)
}
function MachinesPage() {
const { api } = useAppContext()
const navigate = useNavigate()
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">
<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 queryClient = useQueryClient()
const machineForSpawn = machines.find((machine) => machine.id === machineId) ?? null
const handleCancel = useCallback(() => {
navigate({ to: '/machines' })
}, [navigate])
const handleSuccess = useCallback((sessionId: string) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions })
navigate({
to: '/sessions/$sessionId',
params: { sessionId },
})
}, [navigate, queryClient])
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">
<div className="flex-1 font-semibold">Create Session</div>
</div>
<SpawnSession
api={api}
machineId={machineId}
machine={machineForSpawn}
onCancel={handleCancel}
onSuccess={handleSuccess}
/>
</div>
)
}
const rootRoute = createRootRoute({
component: App,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <Navigate to="/sessions" replace />,
})
const sessionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions',
component: SessionsPage,
})
const sessionRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId',
component: SessionPage,
})
const machinesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/machines',
component: MachinesPage,
})
const spawnRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/machines/$machineId/spawn',
component: SpawnPage,
})
export const routeTree = rootRoute.addChildren([
indexRoute,
sessionsRoute,
sessionRoute,
machinesRoute,
spawnRoute,
])
type RouterHistory = Parameters<typeof createRouter>[0]['history']
export function createAppRouter(history?: RouterHistory) {
return createRouter({ routeTree, history })
}
export type AppRouter = ReturnType<typeof createAppRouter>
declare module '@tanstack/react-router' {
interface Register {
router: AppRouter
}
}