diff --git a/bun.lock b/bun.lock index ec88d389..f1f6d509 100644 --- a/bun.lock +++ b/bun.lock @@ -79,6 +79,8 @@ "@radix-ui/react-slot": "^1.2.0", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", + "@tanstack/react-query": "^5.71.10", + "@tanstack/react-query-devtools": "^5.71.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^7.0.0", @@ -632,6 +634,14 @@ "@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/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=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="], + + "@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=="], + "@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=="], diff --git a/web/package.json b/web/package.json index 60b888e6..589a1c2e 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,8 @@ "@radix-ui/react-slot": "^1.2.0", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", + "@tanstack/react-query": "^5.71.10", + "@tanstack/react-query-devtools": "^5.71.10", "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 4ac04cd9..eb4a9221 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' -import { usePlatform } from '@/hooks/usePlatform' import { useSocket } from '@/hooks/useSocket' -import type { DecryptedMessage, Machine, Session, SessionSummary, SyncEvent } from '@/types/api' +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' @@ -36,96 +41,9 @@ function getDeepLinkedSessionId(): string | null { return null } -function makeClientSideId(prefix: string): string { - if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { - return `${prefix}-${crypto.randomUUID()}` - } - return `${prefix}-${Date.now()}-${Math.random()}` -} - -function isUserMessage(msg: DecryptedMessage): boolean { - const content = msg.content - if (content && typeof content === 'object' && 'role' in content) { - return (content as { role: string }).role === 'user' - } - return false -} - -function compareMessages(a: DecryptedMessage, b: DecryptedMessage): number { - const aSeq = typeof a.seq === 'number' ? a.seq : null - const bSeq = typeof b.seq === 'number' ? b.seq : null - if (aSeq !== null && bSeq !== null && aSeq !== bSeq) { - return aSeq - bSeq - } - if (a.createdAt !== b.createdAt) { - return a.createdAt - b.createdAt - } - return a.id.localeCompare(b.id) -} - -function mergeMessages(existing: DecryptedMessage[], incoming: DecryptedMessage[]): DecryptedMessage[] { - if (existing.length === 0) { - return [...incoming].sort(compareMessages) - } - if (incoming.length === 0) { - return [...existing].sort(compareMessages) - } - - const byId = new Map() - for (const msg of existing) { - byId.set(msg.id, msg) - } - for (const msg of incoming) { - byId.set(msg.id, msg) - } - - let merged = Array.from(byId.values()) - - const incomingLocalIds = new Set() - for (const msg of incoming) { - if (msg.localId) { - incomingLocalIds.add(msg.localId) - } - } - - // If we received a stored message with a localId, drop any optimistic bubble with the same localId. - if (incomingLocalIds.size > 0) { - merged = merged.filter((msg) => { - if (!msg.localId || !incomingLocalIds.has(msg.localId)) { - return true - } - return !msg.status - }) - } - - // Fallback: if an optimistic message was marked as sent but we didn't get a localId echo, - // drop it when a server user message appears close in time. - const optimisticMessages = merged.filter((m) => m.localId && m.status) - const nonOptimisticMessages = merged.filter((m) => !m.localId || !m.status) - const result: DecryptedMessage[] = [...nonOptimisticMessages] - - for (const optimistic of optimisticMessages) { - if (optimistic.status === 'sent') { - const hasServerUserMessage = nonOptimisticMessages.some((m) => - !m.status && - isUserMessage(m) && - Math.abs(m.createdAt - optimistic.createdAt) < 10_000 - ) - if (hasServerUserMessage) { - continue - } - } - result.push(optimistic) - } - - result.sort(compareMessages) - return result -} - export function App() { const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource() - const { token, api, isLoading: isAuthLoading, error: authError, user } = useAuth(authSource) - const { haptic } = usePlatform() + const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource) const [screen, setScreen] = useState(() => { const deepLinkedSessionId = getDeepLinkedSessionId() @@ -143,27 +61,6 @@ export function App() { } }, []) - const [sessions, setSessions] = useState([]) - const [sessionsLoading, setSessionsLoading] = useState(false) - const [sessionsError, setSessionsError] = useState(null) - - const selectedSessionId = screen.type === 'session' ? screen.sessionId : null - const [selectedSession, setSelectedSession] = useState(null) - - const [messages, setMessages] = useState([]) - const [messagesLoading, setMessagesLoading] = useState(false) - const [messagesLoadingMore, setMessagesLoadingMore] = useState(false) - const [messagesHasMore, setMessagesHasMore] = useState(false) - const [messagesNextBeforeSeq, setMessagesNextBeforeSeq] = useState(null) - const [messagesWarning, setMessagesWarning] = useState(null) - - const [machines, setMachines] = useState([]) - const [machinesLoading, setMachinesLoading] = useState(false) - const [machinesError, setMachinesError] = useState(null) - - const [isSending, setIsSending] = useState(false) - const syncInFlightRef = useRef(false) - useEffect(() => { const tg = getTelegramWebApp() tg?.ready() @@ -258,163 +155,58 @@ export function App() { backButton.hide() } }, [goBack, screen.type]) + 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 loadSessions = useCallback(async () => { - if (!api) return - setSessionsLoading(true) - setSessionsError(null) - try { - const res = await api.getSessions() - setSessions(res.sessions) - } catch (e) { - setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions') - } finally { - setSessionsLoading(false) + const refreshSessions = useCallback(() => { + void refetchSessions() + }, [refetchSessions]) + + const refreshSelectedSession = useCallback(() => { + if (!selectedSessionId) return + void refetchSession() + void refetchMessages() + }, [selectedSessionId, refetchMessages, refetchSession]) + + const handleSocketConnect = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + if (selectedSessionId) { + void queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) + void queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) }) } - }, [api]) + }, [queryClient, selectedSessionId]) - const loadSession = useCallback(async (sessionId: string) => { - if (!api) return - const res = await api.getSession(sessionId) - setSelectedSession(res.session) - }, [api]) - - const loadMessages = useCallback(async ( - sessionId: string, - options: { beforeSeq?: number | null; appendOlder?: boolean } = {} - ) => { - if (!api) return - - if (options.appendOlder) { - setMessagesLoadingMore(true) - } else { - setMessagesLoading(true) - } - - try { - const res = await api.getMessages(sessionId, { - limit: 50, - beforeSeq: options.beforeSeq ?? null - }) - - setMessages((prev) => mergeMessages(prev, res.messages)) - setMessagesHasMore(res.page.hasMore) - setMessagesNextBeforeSeq(res.page.nextBeforeSeq) - setMessagesWarning(null) - } catch (e) { - setMessagesWarning(e instanceof Error ? e.message : 'Failed to load messages') - } finally { - setMessagesLoading(false) - setMessagesLoadingMore(false) - } - }, [api]) - - const syncSessionAndMessages = useCallback(async (sessionId: string) => { - if (!api) return - if (messagesLoading || messagesLoadingMore) return - if (syncInFlightRef.current) return - syncInFlightRef.current = true - - try { - const [sessionRes, messagesRes] = await Promise.all([ - api.getSession(sessionId).catch(() => null), - api.getMessages(sessionId, { limit: 50 }).catch(() => null) - ]) - - if (sessionRes) { - setSelectedSession(sessionRes.session) - } - - if (messagesRes) { - setMessages((prev) => mergeMessages(prev, messagesRes.messages)) - setMessagesHasMore(messagesRes.page.hasMore) - setMessagesNextBeforeSeq(messagesRes.page.nextBeforeSeq) - } - } finally { - syncInFlightRef.current = false - } - }, [api, messagesLoading, messagesLoadingMore]) - - const retryMessage = useCallback((localId: string) => { - const message = messages.find(m => m.localId === localId) - if (!message?.originalText || !api || !selectedSessionId) return - - const text = message.originalText - - // Update status to sending - setMessages((prev) => - prev.map(m => m.localId === localId - ? { ...m, status: 'sending' as const } - : m - ) - ) - - api.sendMessage(selectedSessionId, text, localId) - .then(() => { - haptic.notification('success') - setMessages((prev) => - prev.map(m => m.localId === localId - ? { ...m, status: 'sent' as const } - : m - ) - ) - }) - .catch(() => { - haptic.notification('error') - setMessages((prev) => - prev.map(m => m.localId === localId - ? { ...m, status: 'failed' as const } - : m - ) - ) - }) - }, [messages, api, selectedSessionId]) - - const loadMachines = useCallback(async () => { - if (!api) return - setMachinesLoading(true) - setMachinesError(null) - try { - const res = await api.getMachines() - setMachines(res.machines) - } catch (e) { - setMachinesError(e instanceof Error ? e.message : 'Failed to load machines') - } finally { - setMachinesLoading(false) - } - }, [api]) - - useEffect(() => { - if (!api) return - loadSessions() - }, [api, loadSessions]) - - useEffect(() => { - if (!api || !selectedSessionId) { - setSelectedSession(null) - setMessages([]) - setMessagesHasMore(false) - setMessagesNextBeforeSeq(null) - setMessagesWarning(null) - return - } - setSelectedSession(null) - setMessages([]) - setMessagesHasMore(false) - setMessagesNextBeforeSeq(null) - setMessagesWarning(null) - - loadSession(selectedSessionId) - loadMessages(selectedSessionId) - }, [api, selectedSessionId, loadSession, loadMessages]) - - useEffect(() => { - if (!api) return - if (screen.type === 'machines' || screen.type === 'spawn') { - loadMachines() - } - }, [api, loadMachines, screen.type]) + const handleSocketEvent = useCallback(() => {}, []) const socketSubscription = useMemo(() => { if (screen.type === 'session') { @@ -430,25 +222,8 @@ export function App() { enabled: Boolean(api && token), token: token ?? '', subscription: socketSubscription, - onConnect: () => { - if (selectedSessionId) { - syncSessionAndMessages(selectedSessionId) - } - }, - onEvent: (event: SyncEvent) => { - if (event.type === 'session-added' || event.type === 'session-updated' || event.type === 'session-removed') { - loadSessions() - if (selectedSessionId && 'sessionId' in event && event.sessionId === selectedSessionId) { - loadSession(selectedSessionId) - } - } - if (event.type === 'message-received' && selectedSessionId && event.sessionId === selectedSessionId) { - setMessages((prev) => mergeMessages(prev, [event.message])) - } - if (event.type === 'machine-updated' && (screen.type === 'machines' || screen.type === 'spawn')) { - loadMachines() - } - } + onConnect: handleSocketConnect, + onEvent: handleSocketEvent, }) // Loading auth source @@ -515,7 +290,7 @@ export function App() { sessions={sessions} onSelect={(sessionId) => navigateTo({ type: 'session', sessionId })} onNewSession={() => navigateTo({ type: 'machines' })} - onRefresh={loadSessions} + onRefresh={refreshSessions} isLoading={sessionsLoading} /> @@ -531,58 +306,11 @@ export function App() { isLoadingMoreMessages={messagesLoadingMore} isSending={isSending} onBack={goBack} - onRefresh={() => { - loadSession(screen.sessionId) - loadMessages(screen.sessionId) - }} + onRefresh={refreshSelectedSession} onLoadMore={() => { - if (messagesNextBeforeSeq === null) return - loadMessages(screen.sessionId, { beforeSeq: messagesNextBeforeSeq, appendOlder: true }) - }} - onSend={(text) => { - if (isSending) return - - // Create optimistic message - const localId = makeClientSideId('local') - const optimisticMessage: DecryptedMessage = { - id: localId, - seq: null, - localId: localId, - content: { role: 'user', content: text }, - createdAt: Date.now(), - status: 'sending', - originalText: text - } - - // Immediately show message - setMessages((prev) => mergeMessages(prev, [optimisticMessage])) - setIsSending(true) - - api.sendMessage(screen.sessionId, text, localId) - .then(() => { - haptic.notification('success') - // Update status to sent - setMessages((prev) => - prev.map(m => m.localId === localId - ? { ...m, status: 'sent' as const } - : m - ) - ) - }) - .catch(() => { - haptic.notification('error') - // Update status to failed - setMessages((prev) => - prev.map(m => m.localId === localId - ? { ...m, status: 'failed' as const } - : m - ) - ) - }) - .finally(() => { - setIsSending(false) - }) + void loadMoreMessages() }} + onSend={sendMessage} onRetryMessage={retryMessage} /> ) : ( @@ -617,7 +345,7 @@ export function App() { machine={machineForSpawn} onCancel={goBack} onSuccess={(sessionId) => { - loadSessions() + refreshSessions() navigateTo({ type: 'session', sessionId }) }} /> diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index c501e09c..39dbd7c2 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -12,6 +12,7 @@ import { HappyThread } from '@/components/AssistantChat/HappyThread' import { useHappyRuntime } from '@/lib/assistant-runtime' import { SessionHeader } from '@/components/SessionHeader' import { usePlatform } from '@/hooks/usePlatform' +import { useSessionActions } from '@/hooks/mutations/useSessionActions' export function SessionChat(props: { api: ApiClient @@ -32,6 +33,7 @@ export function SessionChat(props: { const controlsDisabled = !props.session.active const normalizedCacheRef = useRef>(new Map()) const blocksByIdRef = useRef>(new Map()) + const { abortSession, setPermissionMode, setModelMode } = useSessionActions(props.api, props.session.id) useEffect(() => { normalizedCacheRef.current.clear() @@ -77,32 +79,32 @@ export function SessionChat(props: { // Permission mode change handler const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => { try { - await props.api.setPermissionMode(props.session.id, mode as 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan') + await setPermissionMode(mode) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set permission mode:', e) } - }, [props.api, props.session.id, props.onRefresh, haptic]) + }, [setPermissionMode, props.onRefresh, haptic]) // Model mode change handler const handleModelModeChange = useCallback(async (mode: ModelMode) => { try { - await props.api.setModelMode(props.session.id, mode as 'default' | 'sonnet' | 'opus') + await setModelMode(mode) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') console.error('Failed to set model mode:', e) } - }, [props.api, props.session.id, props.onRefresh, haptic]) + }, [setModelMode, props.onRefresh, haptic]) // Abort handler const handleAbort = useCallback(async () => { - await props.api.abortSession(props.session.id) + await abortSession() props.onRefresh() - }, [props.api, props.session.id, props.onRefresh]) + }, [abortSession, props.onRefresh]) const runtime = useHappyRuntime({ session: props.session, diff --git a/web/src/components/SpawnSession.tsx b/web/src/components/SpawnSession.tsx index b93b84a7..b17e66c0 100644 --- a/web/src/components/SpawnSession.tsx +++ b/web/src/components/SpawnSession.tsx @@ -4,6 +4,7 @@ import type { Machine } from '@/types/api' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { usePlatform } from '@/hooks/usePlatform' +import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' function getMachineTitle(machine: Machine | null): string { if (!machine) return 'Machine' @@ -21,8 +22,8 @@ export function SpawnSession(props: { }) { const { haptic } = usePlatform() const [directory, setDirectory] = useState('') - const [isWorking, setIsWorking] = useState(false) const [error, setError] = useState(null) + const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) const machineTitle = useMemo(() => getMachineTitle(props.machine), [props.machine]) @@ -30,10 +31,9 @@ export function SpawnSession(props: { const trimmed = directory.trim() if (!trimmed) return - setIsWorking(true) setError(null) try { - const result = await props.api.spawnSession(props.machineId, trimmed) + const result = await spawnSession({ machineId: props.machineId, directory: trimmed }) if (result.type === 'success') { haptic.notification('success') props.onSuccess(result.sessionId) @@ -44,8 +44,6 @@ export function SpawnSession(props: { } catch (e) { haptic.notification('error') setError(e instanceof Error ? e.message : 'Failed to spawn session') - } finally { - setIsWorking(false) } } @@ -68,9 +66,9 @@ export function SpawnSession(props: { className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" /> - {error ? ( + {(error ?? spawnError) ? (
- {error} + {error ?? spawnError}
) : null} @@ -78,15 +76,15 @@ export function SpawnSession(props: { diff --git a/web/src/hooks/mutations/useSendMessage.ts b/web/src/hooks/mutations/useSendMessage.ts new file mode 100644 index 00000000..6830d2cc --- /dev/null +++ b/web/src/hooks/mutations/useSendMessage.ts @@ -0,0 +1,134 @@ +import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { DecryptedMessage, MessagesResponse } from '@/types/api' +import { makeClientSideId, upsertMessagesInCache } from '@/lib/messages' +import { queryKeys } from '@/lib/query-keys' +import { usePlatform } from '@/hooks/usePlatform' + +type SendMessageInput = { + sessionId: string + text: string + localId: string + createdAt: number +} + +function updateMessageStatus( + data: InfiniteData | undefined, + localId: string, + status: DecryptedMessage['status'], +): InfiniteData | undefined { + if (!data) return data + + const pages = data.pages.map((page) => ({ + ...page, + messages: page.messages.map((message) => + message.localId === localId + ? { ...message, status } + : message + ), + })) + + return { + ...data, + pages, + } +} + +function findMessageByLocalId( + data: InfiniteData | undefined, + localId: string, +): DecryptedMessage | null { + if (!data) return null + for (const page of data.pages) { + const match = page.messages.find((message) => message.localId === localId) + if (match) return match + } + return null +} + +export function useSendMessage(api: ApiClient | null, sessionId: string | null): { + sendMessage: (text: string) => void + retryMessage: (localId: string) => void + isSending: boolean +} { + const queryClient = useQueryClient() + const { haptic } = usePlatform() + + const mutation = useMutation({ + mutationFn: async (input: SendMessageInput) => { + if (!api) { + throw new Error('API unavailable') + } + await api.sendMessage(input.sessionId, input.text, input.localId) + }, + onMutate: async (input) => { + const optimisticMessage: DecryptedMessage = { + id: input.localId, + seq: null, + localId: input.localId, + content: { role: 'user', content: input.text }, + createdAt: input.createdAt, + status: 'sending', + originalText: input.text, + } + + queryClient.setQueryData>( + queryKeys.messages(input.sessionId), + (data) => upsertMessagesInCache(data, [optimisticMessage]), + ) + }, + onSuccess: (_, input) => { + queryClient.setQueryData>( + queryKeys.messages(input.sessionId), + (data) => updateMessageStatus(data, input.localId, 'sent'), + ) + haptic.notification('success') + }, + onError: (_, input) => { + queryClient.setQueryData>( + queryKeys.messages(input.sessionId), + (data) => updateMessageStatus(data, input.localId, 'failed'), + ) + haptic.notification('error') + }, + }) + + const sendMessage = (text: string) => { + if (!api || !sessionId) return + if (mutation.isPending) return + const localId = makeClientSideId('local') + mutation.mutate({ + sessionId, + text, + localId, + createdAt: Date.now(), + }) + } + + const retryMessage = (localId: string) => { + if (!api || !sessionId) return + if (mutation.isPending) return + + const data = queryClient.getQueryData>(queryKeys.messages(sessionId)) + const message = findMessageByLocalId(data, localId) + if (!message?.originalText) return + + queryClient.setQueryData>( + queryKeys.messages(sessionId), + (current) => updateMessageStatus(current, localId, 'sending'), + ) + + mutation.mutate({ + sessionId, + text: message.originalText, + localId, + createdAt: message.createdAt, + }) + } + + return { + sendMessage, + retryMessage, + isSending: mutation.isPending, + } +} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts new file mode 100644 index 00000000..279ada74 --- /dev/null +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -0,0 +1,73 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { ModelMode, PermissionMode } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +type PermissionModeValue = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' +type ModelModeValue = 'default' | 'sonnet' | 'opus' + +function toPermissionMode(mode: PermissionMode): PermissionModeValue { + if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan') { + return mode + } + return 'default' +} + +function toModelMode(mode: ModelMode): ModelModeValue { + if (mode === 'sonnet' || mode === 'opus') { + return mode + } + return 'default' +} + +export function useSessionActions(api: ApiClient | null, sessionId: string | null): { + abortSession: () => Promise + setPermissionMode: (mode: PermissionMode) => Promise + setModelMode: (mode: ModelMode) => Promise + isPending: boolean +} { + const queryClient = useQueryClient() + + const invalidateSession = async () => { + if (!sessionId) return + await queryClient.invalidateQueries({ queryKey: queryKeys.session(sessionId) }) + await queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + } + + const abortMutation = useMutation({ + mutationFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.abortSession(sessionId) + }, + onSuccess: () => void invalidateSession(), + }) + + const permissionMutation = useMutation({ + mutationFn: async (mode: PermissionMode) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.setPermissionMode(sessionId, toPermissionMode(mode)) + }, + onSuccess: () => void invalidateSession(), + }) + + const modelMutation = useMutation({ + mutationFn: async (mode: ModelMode) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.setModelMode(sessionId, toModelMode(mode)) + }, + onSuccess: () => void invalidateSession(), + }) + + return { + abortSession: abortMutation.mutateAsync, + setPermissionMode: permissionMutation.mutateAsync, + setModelMode: modelMutation.mutateAsync, + isPending: abortMutation.isPending || permissionMutation.isPending || modelMutation.isPending, + } +} diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts new file mode 100644 index 00000000..c04c24fd --- /dev/null +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { SpawnResponse } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +type SpawnInput = { + machineId: string + directory: string + agent?: 'claude' | 'codex' +} + +export function useSpawnSession(api: ApiClient | null): { + spawnSession: (input: SpawnInput) => Promise + isPending: boolean + error: string | null +} { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: async (input: SpawnInput) => { + if (!api) { + throw new Error('API unavailable') + } + return await api.spawnSession(input.machineId, input.directory, input.agent) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + }, + }) + + return { + spawnSession: mutation.mutateAsync, + isPending: mutation.isPending, + error: mutation.error instanceof Error ? mutation.error.message : mutation.error ? 'Failed to spawn session' : null, + } +} diff --git a/web/src/hooks/queries/useMachines.ts b/web/src/hooks/queries/useMachines.ts new file mode 100644 index 00000000..e8bbc9a7 --- /dev/null +++ b/web/src/hooks/queries/useMachines.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { Machine } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useMachines(api: ApiClient | null, enabled: boolean): { + machines: Machine[] + isLoading: boolean + error: string | null + refetch: () => Promise +} { + const query = useQuery({ + queryKey: queryKeys.machines, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + return await api.getMachines() + }, + enabled: Boolean(api && enabled), + }) + + return { + machines: query.data?.machines ?? [], + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load machines' : null, + refetch: query.refetch, + } +} diff --git a/web/src/hooks/queries/useMessages.ts b/web/src/hooks/queries/useMessages.ts new file mode 100644 index 00000000..84067c82 --- /dev/null +++ b/web/src/hooks/queries/useMessages.ts @@ -0,0 +1,61 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import type { ApiClient } from '@/api/client' +import type { DecryptedMessage, MessagesResponse } from '@/types/api' +import { mergeMessages } from '@/lib/messages' +import { queryKeys } from '@/lib/query-keys' + +export function useMessages(api: ApiClient | null, sessionId: string | null): { + messages: DecryptedMessage[] + warning: string | null + isLoading: boolean + isLoadingMore: boolean + hasMore: boolean + loadMore: () => Promise + refetch: () => Promise +} { + const resolvedSessionId = sessionId ?? 'unknown' + const query = useInfiniteQuery({ + queryKey: queryKeys.messages(resolvedSessionId), + queryFn: async ({ pageParam }) => { + if (!api || !sessionId) { + throw new Error('Messages unavailable') + } + const beforeSeq = typeof pageParam === 'number' ? pageParam : null + return await api.getMessages(sessionId, { limit: 50, beforeSeq }) + }, + initialPageParam: null, + getNextPageParam: (lastPage) => + lastPage.page.hasMore ? lastPage.page.nextBeforeSeq : undefined, + enabled: Boolean(api && sessionId), + }) + + const messages = useMemo(() => { + const pages = query.data?.pages ?? [] + let merged: DecryptedMessage[] = [] + for (const page of pages) { + merged = mergeMessages(merged, page.messages) + } + return merged + }, [query.data?.pages]) + + const warning = useMemo(() => { + if (!query.error) return null + return query.error instanceof Error ? query.error.message : 'Failed to load messages' + }, [query.error]) + + const loadMore = async () => { + if (!query.hasNextPage || query.isFetchingNextPage) return + await query.fetchNextPage() + } + + return { + messages, + warning, + isLoading: query.isLoading, + isLoadingMore: query.isFetchingNextPage, + hasMore: Boolean(query.hasNextPage), + loadMore, + refetch: query.refetch, + } +} diff --git a/web/src/hooks/queries/useSession.ts b/web/src/hooks/queries/useSession.ts new file mode 100644 index 00000000..655e5e77 --- /dev/null +++ b/web/src/hooks/queries/useSession.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { Session } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useSession(api: ApiClient | null, sessionId: string | null): { + session: Session | null + isLoading: boolean + error: string | null + refetch: () => Promise +} { + const resolvedSessionId = sessionId ?? 'unknown' + const query = useQuery({ + queryKey: queryKeys.session(resolvedSessionId), + queryFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + return await api.getSession(sessionId) + }, + enabled: Boolean(api && sessionId), + }) + + return { + session: query.data?.session ?? null, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load session' : null, + refetch: query.refetch, + } +} diff --git a/web/src/hooks/queries/useSessions.ts b/web/src/hooks/queries/useSessions.ts new file mode 100644 index 00000000..8e4a7b61 --- /dev/null +++ b/web/src/hooks/queries/useSessions.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { SessionSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useSessions(api: ApiClient | null): { + sessions: SessionSummary[] + isLoading: boolean + error: string | null + refetch: () => Promise +} { + const query = useQuery({ + queryKey: queryKeys.sessions, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + return await api.getSessions() + }, + enabled: Boolean(api), + }) + + return { + sessions: query.data?.sessions ?? [], + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load sessions' : null, + refetch: query.refetch, + } +} diff --git a/web/src/hooks/useSocket.ts b/web/src/hooks/useSocket.ts index 499087ef..9f5773f0 100644 --- a/web/src/hooks/useSocket.ts +++ b/web/src/hooks/useSocket.ts @@ -1,6 +1,9 @@ import { useEffect, useRef } from 'react' +import { useQueryClient, type InfiniteData } from '@tanstack/react-query' import { io } from 'socket.io-client' -import type { SyncEvent } from '@/types/api' +import type { MessagesResponse, SyncEvent } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' +import { upsertMessagesInCache } from '@/lib/messages' function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' @@ -21,6 +24,7 @@ export function useSocket(options: { onDisconnect?: (reason: string) => void onError?: (error: unknown) => void }): void { + const queryClient = useQueryClient() const onEventRef = useRef(options.onEvent) const onConnectRef = useRef(options.onConnect) const onDisconnectRef = useRef(options.onDisconnect) @@ -75,10 +79,42 @@ export function useSocket(options: { onDisconnectRef.current?.(reason) } + const handleSyncEvent = (event: SyncEvent) => { + if (event.type === 'message-received') { + queryClient.setQueryData>( + queryKeys.messages(event.sessionId), + (data) => upsertMessagesInCache(data, [event.message]), + ) + // Mark stale so the initial query still fetches history when it mounts. + void queryClient.invalidateQueries({ + queryKey: queryKeys.messages(event.sessionId), + refetchType: 'none', + }) + } + + if (event.type === 'session-added' || event.type === 'session-updated' || event.type === 'session-removed') { + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + if ('sessionId' in event) { + if (event.type === 'session-removed') { + void queryClient.removeQueries({ queryKey: queryKeys.session(event.sessionId) }) + void queryClient.removeQueries({ queryKey: queryKeys.messages(event.sessionId) }) + } else { + void queryClient.invalidateQueries({ queryKey: queryKeys.session(event.sessionId) }) + } + } + } + + if (event.type === 'machine-updated') { + void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) + } + + onEventRef.current(event) + } + socket.on('update', (event: unknown) => { if (!isObject(event)) return if (typeof event.type !== 'string') return - onEventRef.current(event as SyncEvent) + handleSyncEvent(event as SyncEvent) }) socket.on('connect_error', (error) => { diff --git a/web/src/lib/messages.ts b/web/src/lib/messages.ts new file mode 100644 index 00000000..0f960e77 --- /dev/null +++ b/web/src/lib/messages.ts @@ -0,0 +1,124 @@ +import type { InfiniteData } from '@tanstack/react-query' +import type { DecryptedMessage, MessagesResponse } from '@/types/api' + +export function makeClientSideId(prefix: string): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return `${prefix}-${crypto.randomUUID()}` + } + return `${prefix}-${Date.now()}-${Math.random()}` +} + +function isUserMessage(msg: DecryptedMessage): boolean { + const content = msg.content + if (content && typeof content === 'object' && 'role' in content) { + return (content as { role: string }).role === 'user' + } + return false +} + +function compareMessages(a: DecryptedMessage, b: DecryptedMessage): number { + const aSeq = typeof a.seq === 'number' ? a.seq : null + const bSeq = typeof b.seq === 'number' ? b.seq : null + if (aSeq !== null && bSeq !== null && aSeq !== bSeq) { + return aSeq - bSeq + } + if (a.createdAt !== b.createdAt) { + return a.createdAt - b.createdAt + } + return a.id.localeCompare(b.id) +} + +export function mergeMessages(existing: DecryptedMessage[], incoming: DecryptedMessage[]): DecryptedMessage[] { + if (existing.length === 0) { + return [...incoming].sort(compareMessages) + } + if (incoming.length === 0) { + return [...existing].sort(compareMessages) + } + + const byId = new Map() + for (const msg of existing) { + byId.set(msg.id, msg) + } + for (const msg of incoming) { + byId.set(msg.id, msg) + } + + let merged = Array.from(byId.values()) + + const incomingLocalIds = new Set() + for (const msg of incoming) { + if (msg.localId) { + incomingLocalIds.add(msg.localId) + } + } + + // If we received a stored message with a localId, drop any optimistic bubble with the same localId. + if (incomingLocalIds.size > 0) { + merged = merged.filter((msg) => { + if (!msg.localId || !incomingLocalIds.has(msg.localId)) { + return true + } + return !msg.status + }) + } + + // Fallback: if an optimistic message was marked as sent but we didn't get a localId echo, + // drop it when a server user message appears close in time. + const optimisticMessages = merged.filter((m) => m.localId && m.status) + const nonOptimisticMessages = merged.filter((m) => !m.localId || !m.status) + const result: DecryptedMessage[] = [...nonOptimisticMessages] + + for (const optimistic of optimisticMessages) { + if (optimistic.status === 'sent') { + const hasServerUserMessage = nonOptimisticMessages.some((m) => + !m.status && + isUserMessage(m) && + Math.abs(m.createdAt - optimistic.createdAt) < 10_000 + ) + if (hasServerUserMessage) { + continue + } + } + result.push(optimistic) + } + + result.sort(compareMessages) + return result +} + +export function upsertMessagesInCache( + data: InfiniteData | undefined, + incoming: DecryptedMessage[], +): InfiniteData { + const mergedIncoming = mergeMessages([], incoming) + + if (!data || data.pages.length === 0) { + return { + pages: [ + { + messages: mergedIncoming, + page: { + limit: 50, + beforeSeq: null, + nextBeforeSeq: null, + hasMore: false, + }, + }, + ], + pageParams: [null], + } + } + + const pages = data.pages.slice() + const first = pages[0] + pages[0] = { + ...first, + messages: mergeMessages(first.messages, mergedIncoming), + } + + return { + ...data, + pages, + } +} diff --git a/web/src/lib/query-client.ts b/web/src/lib/query-client.ts new file mode 100644 index 00000000..d42e2b7c --- /dev/null +++ b/web/src/lib/query-client.ts @@ -0,0 +1,14 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5_000, + refetchOnWindowFocus: false, + retry: 1, + }, + mutations: { + retry: 0, + }, + }, +}) diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts new file mode 100644 index 00000000..d0a95d56 --- /dev/null +++ b/web/src/lib/query-keys.ts @@ -0,0 +1,6 @@ +export const queryKeys = { + sessions: ['sessions'] as const, + session: (sessionId: string) => ['session', sessionId] as const, + messages: (sessionId: string) => ['messages', sessionId] as const, + machines: ['machines'] as const, +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 8a625985..c6e2281e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,9 +1,12 @@ 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 './index.css' import { registerSW } from 'virtual:pwa-register' import { isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram' +import { queryClient } from './lib/query-client' async function bootstrap() { // Only load Telegram SDK in Telegram environment (with 3s timeout) @@ -34,7 +37,10 @@ async function bootstrap() { ReactDOM.createRoot(document.getElementById('root')!).render( - + + + {import.meta.env.DEV ? : null} + ) }