Files
hapi/web/src/router.tsx
T
ecfe0eff60 feat(web): 优化会话列表展示 - 按目录分组并显示更新时间 (#22)
* feat(web): add session list grouping by project directory

- Group sessions by project path with collapsible sections
- Sort groups by active status first, then by latest update time
- Sort sessions within each group by update time (descending)
- Add visual indicators for groups with active sessions
- Display project count in header statistics

Co-authored-by: weishu <twsxtd@gmail.com>
2025-12-30 17:42:20 +08:00

325 lines
10 KiB
TypeScript

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 { NewSession } from '@/components/NewSession'
import { LoadingState } from '@/components/LoadingState'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { isTelegramApp } from '@/hooks/useTelegram'
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 { useSendMessage } from '@/hooks/mutations/useSendMessage'
import { queryKeys } from '@/lib/query-keys'
import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file'
import TerminalPage from '@/routes/sessions/terminal'
function BackIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="15 18 9 12 15 6" />
</svg>
)
}
function PlusIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
}
function SessionsPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const { sessions, isLoading, error, refetch } = useSessions(api)
const handleRefresh = useCallback(() => {
void refetch()
}, [refetch])
const projectCount = new Set(sessions.map(s => s.metadata?.path ?? 'Other')).size
return (
<div className="flex h-full flex-col">
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto w-full max-w-content flex items-center justify-between px-3 py-2">
<div className="text-xs text-[var(--app-hint)]">
{sessions.length} sessions in {projectCount} projects
</div>
<button
type="button"
onClick={() => navigate({ to: '/sessions/new' })}
className="session-list-new-button p-1.5 rounded-full text-[var(--app-link)] transition-colors"
title="New Session"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{error ? (
<div className="mx-auto w-full max-w-content px-3 py-2">
<div className="text-sm text-red-600">{error}</div>
</div>
) : null}
<SessionList
sessions={sessions}
onSelect={(sessionId) => navigate({
to: '/sessions/$sessionId',
params: { sessionId },
})}
onNewSession={() => navigate({ to: '/sessions/new' })}
onRefresh={handleRefresh}
isLoading={isLoading}
renderHeader={false}
/>
</div>
</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)
// Get agent type from session metadata for slash commands
const agentType = session?.metadata?.flavor ?? 'claude'
const {
getSuggestions: getSlashSuggestions,
} = useSlashCommands(api, sessionId, agentType)
const refreshSelectedSession = useCallback(() => {
void refetchSession()
void refetchMessages()
}, [refetchMessages, refetchSession])
if (!session) {
return (
<div className="flex-1 flex items-center justify-center p-4">
<LoadingState label="Loading session…" className="text-sm" />
</div>
)
}
return (
<SessionChat
api={api}
session={session}
messages={messages}
messagesWarning={messagesWarning}
hasMoreMessages={messagesHasMore}
isLoadingMessages={messagesLoading}
isLoadingMoreMessages={messagesLoadingMore}
isSending={isSending}
onBack={goBack}
onRefresh={refreshSelectedSession}
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onRetryMessage={retryMessage}
autocompleteSuggestions={getSlashSuggestions}
/>
)
}
function NewSessionPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const goBack = useAppGoBack()
const queryClient = useQueryClient()
const { machines, isLoading: machinesLoading, error: machinesError } = useMachines(api, true)
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])
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">Create Session</div>
</div>
{machinesError ? (
<div className="p-3 text-sm text-red-600">
{machinesError}
</div>
) : null}
<NewSession
api={api}
machines={machines}
isLoading={machinesLoading}
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 sessionFilesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId/files',
component: FilesPage,
})
const sessionTerminalRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId/terminal',
component: TerminalPage,
})
type SessionFileSearch = {
path: string
staged?: boolean
}
const sessionFileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId/file',
validateSearch: (search: Record<string, unknown>): 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
return staged === undefined ? { path } : { path, staged }
},
component: FilePage,
})
const newSessionRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/new',
component: NewSessionPage,
})
export const routeTree = rootRoute.addChildren([
indexRoute,
sessionsRoute,
sessionRoute,
sessionTerminalRoute,
sessionFilesRoute,
sessionFileRoute,
newSessionRoute,
])
type RouterHistory = Parameters<typeof createRouter>[0]['history']
export function createAppRouter(history?: RouterHistory) {
return createRouter({
routeTree,
history,
scrollRestoration: true,
})
}
export type AppRouter = ReturnType<typeof createAppRouter>
declare module '@tanstack/react-router' {
interface Register {
router: AppRouter
}
}