diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ab0798c2..a92a0c397 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' import { useViewportHeight } from '@/hooks/useViewportHeight' import { useVisibilityReporter } from '@/hooks/useVisibilityReporter' +import { useAutoSpawn } from '@/hooks/useAutoSpawn' import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' import { fetchLatestMessages } from '@/lib/message-window-store' @@ -143,12 +144,13 @@ function AppInner() { if (!token || !api) return const { pathname, search, hash, state } = router.history.location const searchParams = new URLSearchParams(search) - if (!searchParams.has('server') && !searchParams.has('hub') && !searchParams.has('token')) { + const authKeys = ['server', 'hub', 'token', 'spawn', 'machine', 'dir', 'boot'] + if (!authKeys.some(k => searchParams.has(k))) { return } - searchParams.delete('server') - searchParams.delete('hub') - searchParams.delete('token') + for (const key of authKeys) { + searchParams.delete(key) + } const nextSearch = searchParams.toString() const nextHref = `${pathname}${nextSearch ? `?${nextSearch}` : ''}${hash}` router.history.replace(nextHref, state) @@ -246,6 +248,9 @@ function AppInner() { return { all: true } }, [selectedSessionId]) + // Auto-spawn session from URL params (?spawn=true&machine=xxx&dir=/path) + useAutoSpawn(api) + const { subscriptionId } = useSSE({ enabled: Boolean(api && token), token: token ?? '', diff --git a/web/src/hooks/useAutoSpawn.ts b/web/src/hooks/useAutoSpawn.ts new file mode 100644 index 000000000..6c323d411 --- /dev/null +++ b/web/src/hooks/useAutoSpawn.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react' +import { useNavigate } from '@tanstack/react-router' +import type { ApiClient } from '@/api/client' + +const AUTO_SPAWN_ACTIVE_POLL_INTERVAL_MS = 500 +const AUTO_SPAWN_ACTIVE_POLL_ATTEMPTS = 30 + +// Capture spawn params at module load time, before any effect can clean the URL +const initialSearch = typeof window !== 'undefined' ? window.location.search : '' +const initialQuery = new URLSearchParams(initialSearch) +const SPAWN_PARAMS = { + spawn: initialQuery.get('spawn') === 'true', + machine: initialQuery.get('machine'), + dir: initialQuery.get('dir'), + boot: initialQuery.get('boot'), +} as const + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +async function waitForSessionActive(api: ApiClient, sessionId: string): Promise { + for (let attempt = 0; attempt < AUTO_SPAWN_ACTIVE_POLL_ATTEMPTS; attempt += 1) { + try { + const { session } = await api.getSession(sessionId) + if (session.active) { + return true + } + } catch { + } + + await sleep(AUTO_SPAWN_ACTIVE_POLL_INTERVAL_MS) + } + + return false +} + +export function useAutoSpawn(api: ApiClient | null) { + const navigate = useNavigate() + const attemptedRef = useRef(false) + + useEffect(() => { + if (!api || attemptedRef.current) return + if (!SPAWN_PARAMS.spawn || !SPAWN_PARAMS.machine || !SPAWN_PARAMS.dir) return + const machineId = SPAWN_PARAMS.machine + const directory = SPAWN_PARAMS.dir + + attemptedRef.current = true + + api.checkMachinePathsExists(machineId, [directory]).then(async (exists) => { + if (exists.exists[directory] === false) { + console.error('Auto-spawn blocked: missing directory requires explicit confirmation') + return + } + + const result = await api.spawnSession(machineId, directory) + if (result.type === 'success') { + if (SPAWN_PARAMS.boot) { + try { + const active = await waitForSessionActive(api, result.sessionId) + if (!active) { + console.error('Auto-spawn boot message skipped: session did not become active in time') + } else { + await api.sendMessage(result.sessionId, SPAWN_PARAMS.boot) + } + } catch (err) { + console.error('Auto-spawn boot message failed:', err) + } + } + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: result.sessionId }, + replace: true, + }) + } else { + console.error('Auto-spawn failed:', result.message) + } + }).catch((err) => { + console.error('Auto-spawn error:', err) + }) + }, [api, navigate]) +}