mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user