diff --git a/bun.lock b/bun.lock index 00497b27..b67ccd06 100644 --- a/bun.lock +++ b/bun.lock @@ -81,6 +81,7 @@ "@shikijs/themes": "^3.20.0", "@tanstack/react-query": "^5.71.10", "@tanstack/react-query-devtools": "^5.71.10", + "@tanstack/react-router": "^1.114.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^7.0.0", @@ -633,6 +634,8 @@ "@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="], + "@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.91.1", "", {}, "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg=="], @@ -641,6 +644,14 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="], + "@tanstack/react-router": ["@tanstack/react-router@1.141.8", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.141.8", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-kPHeS3dF2kBBvFglRpGrHWKnlu6wmUpa7C6aKakI10d7vP+l42XNqe6ARl+9KwX5ujMgCvZ7lgknbZy5L2wiFA=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.141.8", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-/wuEk6/FzzpSC3hkWFE0SU3+eunmLQdzp91MqrPQOOLKnJJb/KgUH8nn0UA3RPdr4y7vLmnzvsUOzrjrEpwYAA=="], + + "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], @@ -919,6 +930,8 @@ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "core-js-compat": ["core-js-compat@3.47.0", "", { "dependencies": { "browserslist": "^4.28.0" } }, "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ=="], @@ -1357,6 +1370,8 @@ "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "issue-parser": ["issue-parser@7.0.1", "", { "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.uniqby": "^4.7.0" } }, "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg=="], @@ -1841,6 +1856,10 @@ "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "seroval": ["seroval@1.4.1", "", {}, "sha512-9GOc+8T6LN4aByLN75uRvMbrwY5RDBW6lSlknsY4LEa9ZmWcxKcRe1G/Q3HZXjltxMHTrStnvrwAICxZrhldtg=="], + + "seroval-plugins": ["seroval-plugins@1.4.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -1977,6 +1996,10 @@ "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -2081,6 +2104,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], diff --git a/web/package.json b/web/package.json index f4d6adb4..7b28daf2 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@shikijs/themes": "^3.20.0", "@tanstack/react-query": "^5.71.10", "@tanstack/react-query-devtools": "^5.71.10", + "@tanstack/react-router": "^1.114.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^7.0.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 4a29a17e..fc6cdd90 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(() => { - 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 ( - <> +
- {screen.type === 'sessions' ? ( -
- {sessionsError ?
{sessionsError}
: null} - navigateTo({ type: 'session', sessionId })} - onNewSession={() => navigateTo({ type: 'machines' })} - onRefresh={refreshSessions} - isLoading={sessionsLoading} - /> -
- ) : screen.type === 'session' ? ( - selectedSession ? ( - { - void loadMoreMessages() - }} - onSend={sendMessage} - onRetryMessage={retryMessage} - /> - ) : ( -
Loading session…
- ) - ) : screen.type === 'machines' ? ( -
-
-
Machines
-
- - {machinesError ? ( -
- {machinesError} -
- ) : null} - - navigateTo({ type: 'spawn', machineId })} - /> -
- ) : ( -
-
-
Create Session
-
- - { - refreshSessions() - navigateTo({ type: 'session', sessionId }) - }} - /> -
- )} +
- +
) } diff --git a/web/src/hooks/useAppGoBack.ts b/web/src/hooks/useAppGoBack.ts new file mode 100644 index 00000000..2d95d340 --- /dev/null +++ b/web/src/hooks/useAppGoBack.ts @@ -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]) +} diff --git a/web/src/lib/app-context.tsx b/web/src/lib/app-context.tsx new file mode 100644 index 00000000..cdb319d8 --- /dev/null +++ b/web/src/lib/app-context.tsx @@ -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(null) + +export function AppContextProvider(props: { + value: AppContextValue + children: ReactNode +}) { + return ( + + {props.children} + + ) +} + +export function useAppContext(): AppContextValue { + const context = useContext(AppContext) + if (!context) { + throw new Error('AppContext is not available') + } + return context +} diff --git a/web/src/main.tsx b/web/src/main.tsx index c6e2281e..3f857164 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - + {import.meta.env.DEV ? : null} diff --git a/web/src/router.tsx b/web/src/router.tsx new file mode 100644 index 00000000..1cc7d0de --- /dev/null +++ b/web/src/router.tsx @@ -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 ( +
+ {error ?
{error}
: null} + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + })} + onNewSession={() => navigate({ to: '/machines' })} + onRefresh={handleRefresh} + isLoading={isLoading} + /> +
+ ) +} + +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 ( +
+ Loading session… +
+ ) + } + + return ( + { + void loadMoreMessages() + }} + onSend={sendMessage} + onRetryMessage={retryMessage} + /> + ) +} + +function MachinesPage() { + const { api } = useAppContext() + const navigate = useNavigate() + const { machines, error: machinesError } = useMachines(api, true) + + return ( +
+
+
Machines
+
+ + {machinesError ? ( +
+ {machinesError} +
+ ) : null} + + navigate({ + to: '/machines/$machineId/spawn', + params: { machineId }, + })} + /> +
+ ) +} + +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 ( +
+
+
Create Session
+
+ + +
+ ) +} + +const rootRoute = createRootRoute({ + component: App, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , +}) + +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[0]['history'] + +export function createAppRouter(history?: RouterHistory) { + return createRouter({ routeTree, history }) +} + +export type AppRouter = ReturnType + +declare module '@tanstack/react-router' { + interface Register { + router: AppRouter + } +}