mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +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:
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user