feat(web): integrate TanStack Query for state management

Replace manual state management with TanStack Query (React Query) for more robust server state handling. This refactoring introduces:

- New hooks for queries: useSessions, useSession, useMessages, useMachines
- New hooks for mutations: useSendMessage, useSessionActions, useSpawnSession
- Centralized query client with optimized configuration (5s staleTime, disabled window focus refetch)
- Query key factory for consistent cache invalidation
- Improved message synchronization via socket events with cache updates
- Optimistic updates for message sending with retry capability
- Simplified App.tsx by removing manual state management logic
- Integrated React Query devtools in development mode

This enables automatic cache management, better error handling, and a foundation for more sophisticated data fetching patterns.
This commit is contained in:
weishu
2025-12-19 18:25:13 +08:00
parent 10bed01358
commit 3a7272d03d
17 changed files with 673 additions and 355 deletions
+134
View File
@@ -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<MessagesResponse> | undefined,
localId: string,
status: DecryptedMessage['status'],
): InfiniteData<MessagesResponse> | 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<MessagesResponse> | 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<InfiniteData<MessagesResponse>>(
queryKeys.messages(input.sessionId),
(data) => upsertMessagesInCache(data, [optimisticMessage]),
)
},
onSuccess: (_, input) => {
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
queryKeys.messages(input.sessionId),
(data) => updateMessageStatus(data, input.localId, 'sent'),
)
haptic.notification('success')
},
onError: (_, input) => {
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
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<InfiniteData<MessagesResponse>>(queryKeys.messages(sessionId))
const message = findMessageByLocalId(data, localId)
if (!message?.originalText) return
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
queryKeys.messages(sessionId),
(current) => updateMessageStatus(current, localId, 'sending'),
)
mutation.mutate({
sessionId,
text: message.originalText,
localId,
createdAt: message.createdAt,
})
}
return {
sendMessage,
retryMessage,
isSending: mutation.isPending,
}
}
@@ -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<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
setModelMode: (mode: ModelMode) => Promise<void>
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,
}
}
@@ -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<SpawnResponse>
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,
}
}