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
+10
View File
@@ -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=="],
+2
View File
@@ -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",
+64 -336
View File
@@ -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<string, DecryptedMessage>()
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<string>()
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<Screen>(() => {
const deepLinkedSessionId = getDeepLinkedSessionId()
@@ -143,27 +61,6 @@ export function App() {
}
}, [])
const [sessions, setSessions] = useState<SessionSummary[]>([])
const [sessionsLoading, setSessionsLoading] = useState<boolean>(false)
const [sessionsError, setSessionsError] = useState<string | null>(null)
const selectedSessionId = screen.type === 'session' ? screen.sessionId : null
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
const [messages, setMessages] = useState<DecryptedMessage[]>([])
const [messagesLoading, setMessagesLoading] = useState<boolean>(false)
const [messagesLoadingMore, setMessagesLoadingMore] = useState<boolean>(false)
const [messagesHasMore, setMessagesHasMore] = useState<boolean>(false)
const [messagesNextBeforeSeq, setMessagesNextBeforeSeq] = useState<number | null>(null)
const [messagesWarning, setMessagesWarning] = useState<string | null>(null)
const [machines, setMachines] = useState<Machine[]>([])
const [machinesLoading, setMachinesLoading] = useState<boolean>(false)
const [machinesError, setMachinesError] = useState<string | null>(null)
const [isSending, setIsSending] = useState<boolean>(false)
const syncInFlightRef = useRef<boolean>(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}
/>
</div>
@@ -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 })
}}
/>
+8 -6
View File
@@ -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<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
const blocksByIdRef = useRef<Map<string, ChatBlock>>(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,
+8 -10
View File
@@ -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<string | null>(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) ? (
<div className="text-sm text-red-600">
{error}
{error ?? spawnError}
</div>
) : null}
@@ -78,15 +76,15 @@ export function SpawnSession(props: {
<Button
variant="secondary"
onClick={props.onCancel}
disabled={isWorking}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={spawn}
disabled={isWorking || !directory.trim()}
disabled={isPending || !directory.trim()}
>
{isWorking ? 'Creating…' : 'Create Session'}
{isPending ? 'Creating…' : 'Create Session'}
</Button>
</div>
</div>
+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,
}
}
+29
View File
@@ -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<unknown>
} {
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,
}
}
+61
View File
@@ -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<unknown>
refetch: () => Promise<unknown>
} {
const resolvedSessionId = sessionId ?? 'unknown'
const query = useInfiniteQuery<MessagesResponse>({
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,
}
}
+30
View File
@@ -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<unknown>
} {
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,
}
}
+29
View File
@@ -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<unknown>
} {
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,
}
}
+38 -2
View File
@@ -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<string, unknown> {
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<InfiniteData<MessagesResponse>>(
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) => {
+124
View File
@@ -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<string, DecryptedMessage>()
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<string>()
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<MessagesResponse> | undefined,
incoming: DecryptedMessage[],
): InfiniteData<MessagesResponse> {
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,
}
}
+14
View File
@@ -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,
},
},
})
+6
View File
@@ -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,
}
+7 -1
View File
@@ -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(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
</QueryClientProvider>
</React.StrictMode>
)
}