import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useLocation, useMatchRoute, useNavigate, useParams, } from '@tanstack/react-router' import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' import { NewSession } from '@/components/NewSession' import { WorkspaceBrowser } from '@/components/WorkspaceBrowser' import { LoadingState } from '@/components/LoadingState' import { useAppContext } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' import { isTelegramApp } from '@/hooks/useTelegram' import { useSidebarResize } from '@/hooks/useSidebarResize' 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 { useSlashCommands } from '@/hooks/queries/useSlashCommands' import { useSkills } from '@/hooks/queries/useSkills' import { useSendMessage } from '@/hooks/mutations/useSendMessage' import { queryKeys } from '@/lib/query-keys' import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message-window-store' import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import type { Machine } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' import SettingsPage from '@/routes/settings' function BackIcon(props: { className?: string }) { return ( ) } function PlusIcon(props: { className?: string }) { return ( ) } function FolderOpenIcon(props: { className?: string }) { return ( ) } function SettingsIcon(props: { className?: string }) { return ( ) } 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) } function SessionsPage() { const { api } = useAppContext() const navigate = useNavigate() const pathname = useLocation({ select: location => location.pathname }) const matchRoute = useMatchRoute() const { t } = useTranslation() const { sessions, isLoading, error, refetch } = useSessions(api) const { machines } = useMachines(api, true) const handleRefresh = useCallback(() => { void refetch() }, [refetch]) const projectCount = useMemo(() => new Set(sessions.map(s => s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other' )).size, [sessions]) const machineLabelsById = useMemo(() => { const labels: Record = {} for (const machine of machines) { labels[machine.id] = getMachineTitle(machine) } return labels }, [machines]) const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' const sidebar = useSidebarResize() return ( {t('sessions.count', { n: sessions.length, m: projectCount })} navigate({ to: '/browse' })} className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors" title={t('browse.nav')} > navigate({ to: '/settings' })} className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors" title={t('settings.title')} > navigate({ to: '/sessions/new' })} className="session-list-new-button p-1.5 rounded-full text-[var(--app-link)] transition-colors" title={t('sessions.new')} > {error ? ( {error} ) : null} navigate({ to: '/sessions/$sessionId', params: { sessionId }, })} onNewSession={() => navigate({ to: '/sessions/new' })} onBrowse={() => navigate({ to: '/browse' })} onRefresh={handleRefresh} isLoading={isLoading} renderHeader={false} api={api} machineLabelsById={machineLabelsById} /> {/* Resize handle - desktop only */} ) } function SessionsIndexPage() { return null } function SessionPage() { const { api } = useAppContext() const { t } = useTranslation() const goBack = useAppGoBack() const navigate = useNavigate() const queryClient = useQueryClient() const { addToast } = useToast() 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, pendingCount, messagesVersion, flushPending, setAtBottom, } = useMessages(api, sessionId) const { sendMessage, retryMessage, isSending, } = useSendMessage(api, sessionId, { isSessionThinking: session?.thinking ?? false, onSuccess: (sentSessionId) => { clearDraftsAfterSend(sentSessionId, sessionId) }, resolveSessionId: async (currentSessionId) => { if (!api || !session || session.active) { return currentSessionId } try { return await api.resumeSession(currentSessionId, { permissionMode: session.permissionMode ?? undefined }) } catch (error) { const message = error instanceof Error ? error.message : 'Resume failed' addToast({ title: 'Resume failed', body: message, sessionId: currentSessionId, url: '' }) throw error } }, onSessionResolved: (resolvedSessionId) => { void (async () => { if (api) { if (session && resolvedSessionId !== session.id) { seedMessageWindowFromSession(session.id, resolvedSessionId) queryClient.setQueryData(queryKeys.session(resolvedSessionId), { session: { ...session, id: resolvedSessionId, active: true } }) } try { await Promise.all([ queryClient.prefetchQuery({ queryKey: queryKeys.session(resolvedSessionId), queryFn: () => api.getSession(resolvedSessionId), }), fetchLatestMessages(api, resolvedSessionId), ]) } catch { } } navigate({ to: '/sessions/$sessionId', params: { sessionId: resolvedSessionId }, replace: true }) })() }, onBlocked: (reason) => { if (reason === 'no-api') { addToast({ title: t('send.blocked.title'), body: t('send.blocked.noConnection'), sessionId: sessionId ?? '', url: '' }) } // 'no-session' and 'pending' don't need toast - either invalid state or expected behavior } }) // Get agent type from session metadata for slash commands const agentType = session?.metadata?.flavor ?? 'claude' const { commands: slashCommands, getSuggestions: getSlashSuggestions, } = useSlashCommands(api, sessionId, agentType) const { getSuggestions: getSkillSuggestions, } = useSkills(api, sessionId) const getAutocompleteSuggestions = useCallback(async (query: string) => { if (query.startsWith('$')) { return await getSkillSuggestions(query) } return await getSlashSuggestions(query) }, [getSkillSuggestions, getSlashSuggestions]) const refreshSelectedSession = useCallback(() => { void refetchSession() void refetchMessages() }, [refetchMessages, refetchSession]) if (!session) { return ( ) } return ( ) } function SessionDetailRoute() { const pathname = useLocation({ select: location => location.pathname }) const { sessionId } = useParams({ from: '/sessions/$sessionId' }) const basePath = `/sessions/${sessionId}` const isChat = pathname === basePath || pathname === `${basePath}/` return isChat ? : } function NewSessionPage() { const { api } = useAppContext() const navigate = useNavigate() const goBack = useAppGoBack() const queryClient = useQueryClient() const { machines, isLoading: machinesLoading, error: machinesError } = useMachines(api, true) const { t } = useTranslation() const { directory: initialDirectory, machineId: initialMachineId } = newSessionRoute.useSearch() const handleCancel = useCallback(() => { navigate({ to: '/sessions' }) }, [navigate]) const handleSuccess = useCallback((sessionId: string) => { void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) // Replace current page with /sessions to clear spawn flow from history navigate({ to: '/sessions', replace: true }) // Then navigate to new session requestAnimationFrame(() => { navigate({ to: '/sessions/$sessionId', params: { sessionId }, }) }) }, [navigate, queryClient]) const handleChooseFolder = useCallback((args: { machineId: string | null; directory: string }) => { // Forward the currently-selected machine so /browse opens scoped to // it rather than falling back to `hapi:lastMachineId`, which can // disagree if the user changed machines without yet creating a // session. navigate({ to: '/browse', search: args.machineId ? { machineId: args.machineId } : {} }) }, [navigate]) return ( {!isTelegramApp() && ( )} {t('newSession.title')} {machinesError ? ( {machinesError} ) : null} ) } function BrowsePage() { const { api } = useAppContext() const navigate = useNavigate() const goBack = useAppGoBack() const { machines, isLoading: machinesLoading } = useMachines(api, true) const { t } = useTranslation() const { machineId: initialMachineId } = browseRoute.useSearch() const handleStartSession = useCallback((machineId: string, directory: string) => { navigate({ to: '/sessions/new', search: { directory, machineId } }) }, [navigate]) return ( {!isTelegramApp() && ( )} {t('browse.title')} ) } const rootRoute = createRootRoute({ component: App, }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => , }) const sessionsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/sessions', component: SessionsPage, }) const sessionsIndexRoute = createRoute({ getParentRoute: () => sessionsRoute, path: '/', component: SessionsIndexPage, }) const sessionDetailRoute = createRoute({ getParentRoute: () => sessionsRoute, path: '$sessionId', component: SessionDetailRoute, }) const sessionFilesRoute = createRoute({ getParentRoute: () => sessionDetailRoute, path: 'files', validateSearch: (search: Record): { tab?: 'changes' | 'directories' } => { const tabValue = typeof search.tab === 'string' ? search.tab : undefined const tab = tabValue === 'directories' ? 'directories' : tabValue === 'changes' ? 'changes' : undefined return tab ? { tab } : {} }, component: FilesPage, }) const sessionTerminalRoute = createRoute({ getParentRoute: () => sessionDetailRoute, path: 'terminal', component: TerminalPage, }) type SessionFileSearch = { path: string staged?: boolean tab?: 'changes' | 'directories' } const sessionFileRoute = createRoute({ getParentRoute: () => sessionDetailRoute, path: 'file', validateSearch: (search: Record): SessionFileSearch => { const path = typeof search.path === 'string' ? search.path : '' const staged = search.staged === true || search.staged === 'true' ? true : search.staged === false || search.staged === 'false' ? false : undefined const tabValue = typeof search.tab === 'string' ? search.tab : undefined const tab = tabValue === 'directories' ? 'directories' : tabValue === 'changes' ? 'changes' : undefined const result: SessionFileSearch = { path } if (staged !== undefined) { result.staged = staged } if (tab !== undefined) { result.tab = tab } return result }, component: FilePage, }) type NewSessionSearch = { directory?: string machineId?: string } const newSessionRoute = createRoute({ getParentRoute: () => sessionsRoute, path: 'new', validateSearch: (search: Record): NewSessionSearch => { const result: NewSessionSearch = {} if (typeof search.directory === 'string' && search.directory) { result.directory = search.directory } if (typeof search.machineId === 'string' && search.machineId) { result.machineId = search.machineId } return result }, component: NewSessionPage, }) const browseRoute = createRoute({ getParentRoute: () => rootRoute, path: '/browse', validateSearch: (search: Record): { machineId?: string } => { if (typeof search.machineId === 'string' && search.machineId) { return { machineId: search.machineId } } return {} }, component: BrowsePage, }) const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage, }) export const routeTree = rootRoute.addChildren([ indexRoute, sessionsRoute.addChildren([ sessionsIndexRoute, newSessionRoute, sessionDetailRoute.addChildren([ sessionTerminalRoute, sessionFilesRoute, sessionFileRoute, ]), ]), browseRoute, settingsRoute, ]) type RouterHistory = Parameters[0]['history'] export function createAppRouter(history?: RouterHistory) { return createRouter({ routeTree, history, scrollRestoration: true, }) } export type AppRouter = ReturnType declare module '@tanstack/react-router' { interface Register { router: AppRouter } }