fix: surface gateway connectivity failures in chat flow

This commit is contained in:
ibelick
2026-02-05 12:40:42 +01:00
parent 0d39ce4d54
commit e9c186ae36
7 changed files with 281 additions and 69 deletions

View File

@@ -1,11 +1,23 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { gatewayConnectCheck } from '../../server/gateway'
export const Route = createFileRoute('/api/ping')({
server: {
handlers: {
GET: async () => {
return json({ ok: true })
try {
await gatewayConnectCheck()
return json({ ok: true })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 503 },
)
}
},
},
},

View File

@@ -7,6 +7,11 @@ import type {
SessionMeta,
} from './types'
type GatewayStatusResponse = {
ok: boolean
error?: string
}
export const chatQueryKeys = {
sessions: ['chat', 'sessions'] as const,
history: function history(friendlyId: string, sessionKey: string) {
@@ -33,6 +38,24 @@ export async function fetchHistory(payload: {
return (await res.json()) as HistoryResponse
}
export async function fetchGatewayStatus(): Promise<GatewayStatusResponse> {
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), 2500)
try {
const res = await fetch('/api/ping', { signal: controller.signal })
if (!res.ok) throw new Error(await readError(res))
return (await res.json()) as GatewayStatusResponse
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Gateway check timed out')
}
throw err
} finally {
window.clearTimeout(timeout)
}
}
export function updateHistoryMessages(
queryClient: QueryClient,
friendlyId: string,

View File

@@ -20,6 +20,7 @@ import {
chatQueryKeys,
appendHistoryMessage,
clearHistoryMessages,
fetchGatewayStatus,
removeHistoryMessageByClientId,
updateHistoryMessageByClientId,
updateSessionLastMessage,
@@ -29,6 +30,7 @@ import { ChatSidebar } from './components/chat-sidebar'
import { ChatHeader } from './components/chat-header'
import { ChatMessageList } from './components/chat-message-list'
import { ChatComposer } from './components/chat-composer'
import { GatewayStatusMessage } from './components/gateway-status-message'
import {
consumePendingSend,
hasPendingGeneration,
@@ -120,6 +122,25 @@ export function ChatScreen({
},
staleTime: Infinity,
})
const gatewayStatusQuery = useQuery({
queryKey: ['gateway', 'status'],
queryFn: fetchGatewayStatus,
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: 'always',
})
const gatewayStatusMountRef = useRef(Date.now())
const gatewayStatusError =
gatewayStatusQuery.error instanceof Error
? gatewayStatusQuery.error.message
: gatewayStatusQuery.data && !gatewayStatusQuery.data.ok
? gatewayStatusQuery.data.error || 'Gateway unavailable'
: null
const gatewayError = gatewayStatusError ?? sessionsError ?? historyError
const handleGatewayRefetch = useCallback(() => {
void gatewayStatusQuery.refetch()
}, [gatewayStatusQuery])
const isSidebarCollapsed = uiQuery.data?.isSidebarCollapsed ?? false
const handleActiveSessionDelete = useCallback(() => {
setError(null)
@@ -166,7 +187,7 @@ export function ChatScreen({
if (error) setError(null)
return
}
const messageText = sessionsError ?? historyError
const messageText = sessionsError ?? historyError ?? gatewayStatusError
if (!messageText) {
if (error?.startsWith('Failed to load')) {
setError(null)
@@ -180,9 +201,18 @@ export function ChatScreen({
? `Failed to load sessions. ${sessionsError}`
: historyError
? `Failed to load history. ${historyError}`
: null
: gatewayStatusError
? `Gateway unavailable. ${gatewayStatusError}`
: null
if (message) setError(message)
}, [error, historyError, isRedirecting, navigate, sessionsError])
}, [
error,
gatewayStatusError,
historyError,
isRedirecting,
navigate,
sessionsError,
])
const shouldRedirectToNew =
!isNewChat &&
@@ -519,7 +549,22 @@ export function ChatScreen({
const historyLoading =
(historyQuery.isLoading && !historyQuery.data) || isRedirecting
const showGatewayDown = Boolean(gatewayStatusError)
const showGatewayNotice =
showGatewayDown &&
gatewayStatusQuery.errorUpdatedAt > gatewayStatusMountRef.current
const historyEmpty = !historyLoading && displayMessages.length === 0
const gatewayNotice = useMemo(() => {
if (!showGatewayNotice) return null
if (!gatewayError) return null
return (
<GatewayStatusMessage
state="error"
error={gatewayError}
onRetry={handleGatewayRefetch}
/>
)
}, [gatewayError, handleGatewayRefetch, showGatewayNotice])
const sidebar = (
<ChatSidebar
@@ -565,25 +610,14 @@ export function ChatScreen({
onOpenSidebar={handleOpenSidebar}
/>
{error && !hideUi ? (
<div className="border-b border-primary-200 bg-primary-100 px-4 py-3 text-sm text-primary-800">
<div className="font-medium">{error}</div>
<div className="text-xs text-primary-700 mt-1">
Check that the dashboard server has access to the Clawdbot
Gateway and that{' '}
<code className="inline-code">CLAWDBOT_GATEWAY_TOKEN</code> (or{' '}
<code className="inline-code">CLAWDBOT_GATEWAY_PASSWORD</code>)
is set in your server environment.
</div>
</div>
) : null}
{hideUi ? null : (
<>
<ChatMessageList
messages={displayMessages}
loading={historyLoading}
empty={historyEmpty}
notice={gatewayNotice}
noticePosition="end"
waitingForResponse={waitingForResponse}
sessionKey={activeCanonicalKey}
pinToTop={pinToTop}

View File

@@ -13,6 +13,9 @@ type ChatMessageListProps = {
messages: Array<GatewayMessage>
loading: boolean
empty: boolean
emptyState?: React.ReactNode
notice?: React.ReactNode
noticePosition?: 'start' | 'end'
waitingForResponse: boolean
sessionKey?: string
pinToTop: boolean
@@ -25,6 +28,9 @@ function ChatMessageListComponent({
messages,
loading,
empty,
emptyState,
notice,
noticePosition = 'start',
waitingForResponse,
sessionKey,
pinToTop,
@@ -106,8 +112,9 @@ function ChatMessageListComponent({
// mt-2 is to fix the prompt-input cut off
<ChatContainerRoot className="flex-1 min-h-0 -mb-4">
<ChatContainerContent className="pt-6" style={contentStyle}>
{empty ? (
<div aria-hidden></div>
{notice && noticePosition === 'start' ? notice : null}
{empty && !notice ? (
(emptyState ?? <div aria-hidden></div>)
) : hasGroup ? (
<>
{displayMessages
@@ -201,6 +208,7 @@ function ChatMessageListComponent({
)
})
)}
{notice && noticePosition === 'end' ? notice : null}
<ChatContainerScrollAnchor
ref={anchorRef as React.RefObject<HTMLDivElement>}
/>
@@ -217,6 +225,9 @@ function areChatMessageListEqual(
prev.messages === next.messages &&
prev.loading === next.loading &&
prev.empty === next.empty &&
prev.emptyState === next.emptyState &&
prev.notice === next.notice &&
prev.noticePosition === next.noticePosition &&
prev.waitingForResponse === next.waitingForResponse &&
prev.sessionKey === next.sessionKey &&
prev.pinToTop === next.pinToTop &&

View File

@@ -0,0 +1,45 @@
import { MessageStatus } from './message-status'
type GatewayStatusMessageProps = {
state: 'checking' | 'error'
error?: string | null
onRetry?: () => void
className?: string
}
export function GatewayStatusMessage({
state,
error,
onRetry,
className,
}: GatewayStatusMessageProps) {
const isChecking = state === 'checking'
const title = isChecking
? 'Checking gateway connection...'
: 'OpenClaw gateway is unreachable'
const description = isChecking
? 'This dashboard needs access to the OpenClaw gateway configured by your server environment variables.'
: ''
return (
<MessageStatus
title={title}
description={
isChecking ? (
description
) : (
<>
We could not reach the gateway from the dashboard server. Start the
gateway and confirm your server environment has{' '}
<span className="font-mono">CLAWDBOT_GATEWAY_URL</span> plus{' '}
<span className="font-mono">CLAWDBOT_GATEWAY_TOKEN</span> (or{' '}
<span className="font-mono">CLAWDBOT_GATEWAY_PASSWORD</span>).
</>
)
}
detail={isChecking ? null : error}
actionLabel={isChecking ? undefined : 'Retry'}
onAction={isChecking ? undefined : onRetry}
className={className}
/>
)
}

View File

@@ -0,0 +1,42 @@
import { Message } from '@/components/prompt-kit/message'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type MessageStatusProps = {
title: string
description: React.ReactNode
detail?: string | null
actionLabel?: string
onAction?: () => void
className?: string
}
export function MessageStatus({
title,
description,
detail,
actionLabel,
onAction,
className,
}: MessageStatusProps) {
return (
<div className={cn('w-full max-w-[900px]', className)}>
<Message>
<div className="w-full rounded-xl border border-primary-200 bg-primary-50 p-4 text-primary-900">
<div className="text-balance font-medium">{title}</div>
<div className="mt-2 text-pretty text-primary-700">{description}</div>
{detail ? (
<div className="mt-2 text-xs text-primary-600">{detail}</div>
) : null}
{actionLabel && onAction ? (
<div className="mt-3">
<Button size="sm" variant="outline" onClick={onAction}>
{actionLabel}
</Button>
</div>
) : null}
</div>
</Message>
</div>
)
}

View File

@@ -28,6 +28,11 @@ type ConnectParams = {
scopes?: Array<string>
}
type GatewayWaiter = {
waitForRes: (id: string) => Promise<unknown>
handleMessage: (evt: MessageEvent) => void
}
function getGatewayConfig() {
const url = process.env.CLAWDBOT_GATEWAY_URL?.trim() || 'ws://127.0.0.1:18789'
const token = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || ''
@@ -43,6 +48,60 @@ function getGatewayConfig() {
return { url, token, password }
}
function buildConnectParams(token: string, password: string): ConnectParams {
return {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'gateway-client',
displayName: 'webclaw',
version: 'dev',
platform: process.platform,
mode: 'ui',
instanceId: randomUUID(),
},
auth: {
token: token || undefined,
password: password || undefined,
},
role: 'operator',
scopes: ['operator.admin'],
}
}
function createGatewayWaiter(): GatewayWaiter {
const waiters = new Map<
string,
{
resolve: (v: unknown) => void
reject: (e: Error) => void
}
>()
function waitForRes(id: string) {
return new Promise<unknown>((resolve, reject) => {
waiters.set(id, { resolve, reject })
})
}
function handleMessage(evt: MessageEvent) {
try {
const data = typeof evt.data === 'string' ? evt.data : ''
const parsed = JSON.parse(data) as GatewayFrame
if (parsed.type !== 'res') return
const w = waiters.get(parsed.id)
if (!w) return
waiters.delete(parsed.id)
if (parsed.ok) w.resolve(parsed.payload)
else w.reject(new Error(parsed.error?.message ?? 'gateway error'))
} catch {
// ignore parse errors
}
}
return { waitForRes, handleMessage }
}
async function wsOpen(ws: WebSocket): Promise<void> {
if (ws.readyState === ws.OPEN) return
await new Promise<void>((resolve, reject) => {
@@ -83,24 +142,7 @@ export async function gatewayRpc<TPayload = unknown>(
// 1) connect handshake (must be first request)
const connectId = randomUUID()
const connectParams: ConnectParams = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'gateway-client',
displayName: 'webclaw',
version: 'dev',
platform: process.platform,
mode: 'ui',
instanceId: randomUUID(),
},
auth: {
token: token || undefined,
password: password || undefined,
},
role: 'operator',
scopes: ['operator.admin'],
}
const connectParams = buildConnectParams(token, password)
const connectReq: GatewayFrame = {
type: 'req',
@@ -117,44 +159,17 @@ export async function gatewayRpc<TPayload = unknown>(
params,
}
// Response waiters keyed by id
const waiters = new Map<
string,
{
resolve: (v: any) => void
reject: (e: Error) => void
}
>()
const waiter = createGatewayWaiter()
const waitForRes = (id: string) =>
new Promise<any>((resolve, reject) => {
waiters.set(id, { resolve, reject })
})
const onMessage = (evt: MessageEvent) => {
try {
const data = typeof evt.data === 'string' ? evt.data : ''
const parsed = JSON.parse(data) as GatewayFrame
if (parsed.type !== 'res') return
const w = waiters.get(parsed.id)
if (!w) return
waiters.delete(parsed.id)
if (parsed.ok) w.resolve(parsed.payload)
else w.reject(new Error(parsed.error?.message ?? 'gateway error'))
} catch {
// ignore parse errors
}
}
ws.addEventListener('message', onMessage)
ws.addEventListener('message', waiter.handleMessage)
ws.send(JSON.stringify(connectReq))
await waitForRes(connectId)
await waiter.waitForRes(connectId)
ws.send(JSON.stringify(req))
const payload = await waitForRes(requestId)
const payload = await waiter.waitForRes(requestId)
ws.removeEventListener('message', onMessage)
ws.removeEventListener('message', waiter.handleMessage)
return payload as TPayload
} finally {
try {
@@ -164,3 +179,33 @@ export async function gatewayRpc<TPayload = unknown>(
}
}
}
export async function gatewayConnectCheck(): Promise<void> {
const { url, token, password } = getGatewayConfig()
const ws = new WebSocket(url)
try {
await wsOpen(ws)
const connectId = randomUUID()
const connectParams = buildConnectParams(token, password)
const connectReq: GatewayFrame = {
type: 'req',
id: connectId,
method: 'connect',
params: connectParams,
}
const waiter = createGatewayWaiter()
ws.addEventListener('message', waiter.handleMessage)
ws.send(JSON.stringify(connectReq))
await waiter.waitForRes(connectId)
ws.removeEventListener('message', waiter.handleMessage)
} finally {
try {
await wsClose(ws)
} catch {
// ignore
}
}
}