Skip to content

Commit fb760a0

Browse files
committed
feat: add reconnection detection and status message
- Update useConnectionStatus to accept onReconnect callback - Detect connection state transitions (disconnected → connected) - Track initial vs subsequent reconnections - Add "Reconnected" status indicator state - Show reconnection message for 2 seconds after reconnection - Invalidate auth queries on reconnection - Use useTimeout hook for auto-hiding reconnection message - Batch UI updates with startTransition for stability Users now see visual feedback when connection is restored.
1 parent 5b60332 commit fb760a0

File tree

4 files changed

+133
-8
lines changed

4 files changed

+133
-8
lines changed

cli/src/chat.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useChatInput } from './hooks/use-chat-input'
1313
import { useClipboard } from './hooks/use-clipboard'
1414
import { useConnectionStatus } from './hooks/use-connection-status'
1515
import { useElapsedTime } from './hooks/use-elapsed-time'
16+
import { useTimeout } from './hooks/use-timeout'
1617
import { useExitHandler } from './hooks/use-exit-handler'
1718
import { useInputHistory } from './hooks/use-input-history'
1819
import { useKeyboardHandlers } from './hooks/use-keyboard-handlers'
@@ -33,6 +34,10 @@ import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
3334
import { loadLocalAgents } from './utils/local-agent-registry'
3435
import { buildMessageTree } from './utils/message-tree-utils'
3536
import { getStatusIndicatorState, type AuthStatus } from './utils/status-indicator-state'
37+
import { authQueryKeys } from './hooks/use-auth-query'
38+
import { RECONNECTION_MESSAGE_DURATION_MS } from '@codebuff/sdk'
39+
import { useQueryClient } from '@tanstack/react-query'
40+
import { startTransition, useTransition } from 'react'
3641
import { computeInputLayoutMetrics } from './utils/text-layout'
3742
import { createMarkdownPalette } from './utils/theme-system'
3843

@@ -81,6 +86,12 @@ export const Chat = ({
8186
const [hasOverflow, setHasOverflow] = useState(false)
8287
const hasOverflowRef = useRef(false)
8388

89+
const queryClient = useQueryClient()
90+
const [, startUiTransition] = useTransition()
91+
92+
const [showReconnectionMessage, setShowReconnectionMessage] = useState(false)
93+
const reconnectionTimeout = useTimeout()
94+
8495
const { separatorWidth, terminalWidth, terminalHeight } =
8596
useTerminalDimensions()
8697

@@ -191,7 +202,31 @@ export const Chat = ({
191202
const sendMessageRef = useRef<SendMessageFn>()
192203

193204
const { statusMessage } = useClipboard()
194-
const isConnected = useConnectionStatus()
205+
206+
const handleReconnection = useCallback(
207+
(isInitialConnection: boolean) => {
208+
// Invalidate auth queries so we refetch with current credentials
209+
queryClient.invalidateQueries({ queryKey: authQueryKeys.all })
210+
211+
startUiTransition(() => {
212+
if (!isInitialConnection) {
213+
setShowReconnectionMessage(true)
214+
reconnectionTimeout.setTimeout(
215+
'reconnection-message',
216+
() => {
217+
startUiTransition(() => {
218+
setShowReconnectionMessage(false)
219+
})
220+
},
221+
RECONNECTION_MESSAGE_DURATION_MS,
222+
)
223+
}
224+
})
225+
},
226+
[queryClient, reconnectionTimeout, startUiTransition],
227+
)
228+
229+
const isConnected = useConnectionStatus(handleReconnection)
195230
const mainAgentTimer = useElapsedTime()
196231
const timerStartTime = mainAgentTimer.startTime
197232

@@ -789,6 +824,7 @@ export const Chat = ({
789824
nextCtrlCWillExit,
790825
isConnected,
791826
authStatus,
827+
showReconnectionMessage,
792828
})
793829
const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle'
794830
const inputBoxTitle = useMemo(() => {
@@ -933,6 +969,7 @@ export const Chat = ({
933969
authStatus={authStatus}
934970
isAtBottom={isAtBottom}
935971
scrollToLatest={scrollToLatest}
972+
statusIndicatorState={statusIndicatorState}
936973
/>
937974
)}
938975

cli/src/components/status-bar.tsx

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useTheme } from '../hooks/use-theme'
66
import { formatElapsedTime } from '../utils/format-elapsed-time'
77

88
import type { StreamStatus } from '../hooks/use-message-queue'
9-
import type { AuthStatus } from '../utils/status-indicator-state'
9+
import type { AuthStatus, StatusIndicatorState } from '../utils/status-indicator-state'
1010

1111
const SHIMMER_INTERVAL_MS = 160
1212

@@ -19,6 +19,7 @@ interface StatusBarProps {
1919
authStatus: AuthStatus
2020
isAtBottom: boolean
2121
scrollToLatest: () => void
22+
statusIndicatorState?: StatusIndicatorState
2223
}
2324

2425
export const StatusBar = ({
@@ -30,6 +31,7 @@ export const StatusBar = ({
3031
authStatus,
3132
isAtBottom,
3233
scrollToLatest,
34+
statusIndicatorState,
3335
}: StatusBarProps) => {
3436
const theme = useTheme()
3537
const [elapsedSeconds, setElapsedSeconds] = useState(0)
@@ -55,12 +57,64 @@ export const StatusBar = ({
5557
}, [timerStartTime, shouldShowTimer])
5658

5759
const renderStatusIndicator = () => {
60+
// Use the unified status indicator state if provided
61+
if (statusIndicatorState) {
62+
switch (statusIndicatorState.kind) {
63+
case 'ctrlC':
64+
return <span fg={theme.secondary}>Press Ctrl-C again to exit</span>
65+
66+
case 'clipboard':
67+
// Use green color for feedback success messages
68+
const isFeedbackSuccess = statusIndicatorState.message.includes('Feedback sent')
69+
return (
70+
<span fg={isFeedbackSuccess ? theme.success : theme.primary}>
71+
{statusIndicatorState.message}
72+
</span>
73+
)
74+
75+
case 'reconnected':
76+
return <span fg={theme.success}>Reconnected</span>
77+
78+
case 'retrying':
79+
return (
80+
<ShimmerText
81+
text="error, retrying..."
82+
primaryColor={theme.warning}
83+
/>
84+
)
85+
86+
case 'connecting':
87+
return <ShimmerText text="connecting..." />
88+
89+
case 'waiting':
90+
return (
91+
<ShimmerText
92+
text="thinking..."
93+
interval={SHIMMER_INTERVAL_MS}
94+
primaryColor={theme.secondary}
95+
/>
96+
)
97+
98+
case 'streaming':
99+
return (
100+
<ShimmerText
101+
text="working..."
102+
interval={SHIMMER_INTERVAL_MS}
103+
primaryColor={theme.secondary}
104+
/>
105+
)
106+
107+
case 'idle':
108+
return null
109+
}
110+
}
111+
112+
// Fallback to old logic if statusIndicatorState not provided
58113
if (nextCtrlCWillExit) {
59114
return <span fg={theme.secondary}>Press Ctrl-C again to exit</span>
60115
}
61116

62117
if (statusMessage) {
63-
// Use green color for feedback success messages
64118
const isFeedbackSuccess = statusMessage.includes('Feedback sent')
65119
return (
66120
<span fg={isFeedbackSuccess ? theme.success : theme.primary}>
@@ -69,7 +123,6 @@ export const StatusBar = ({
69123
)
70124
}
71125

72-
// Retryable server-side or transient network error: communicate that we're retrying
73126
if (authStatus === 'retrying') {
74127
return (
75128
<ShimmerText
@@ -79,7 +132,6 @@ export const StatusBar = ({
79132
)
80133
}
81134

82-
// Show connecting if service is disconnected OR auth service is unreachable
83135
if (!isConnected || authStatus === 'unreachable') {
84136
return <ShimmerText text="connecting..." />
85137
}

cli/src/hooks/use-connection-status.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useEffect, useRef, useState } from 'react'
22

33
import { getCodebuffClient } from '../utils/codebuff-client'
44
import { logger } from '../utils/logger'
@@ -36,10 +36,17 @@ export function getNextInterval(consecutiveSuccesses: number): number {
3636
/**
3737
* Hook to monitor connection status to the Codebuff backend.
3838
* Uses adaptive exponential backoff to reduce polling frequency when connection is stable.
39+
*
40+
* When the connection transitions from disconnected to connected, the optional
41+
* onReconnect callback is invoked with a boolean indicating whether this was
42+
* the initial connection (true) or a subsequent reconnection (false).
3943
*/
40-
export const useConnectionStatus = () => {
41-
44+
export const useConnectionStatus = (
45+
onReconnect?: (isInitialConnection: boolean) => void,
46+
) => {
4247
const [isConnected, setIsConnected] = useState(true)
48+
// null = never connected, false = was disconnected, true = was connected
49+
const previousConnectedRef = useRef<boolean | null>(null)
4350

4451
useEffect(() => {
4552
let isMounted = true
@@ -57,6 +64,7 @@ export const useConnectionStatus = () => {
5764
if (!client) {
5865
if (isMounted) {
5966
setIsConnected(false)
67+
previousConnectedRef.current = false
6068
consecutiveSuccesses = 0
6169
currentInterval = HEALTH_CHECK_CONFIG.INITIAL_INTERVAL
6270
logger.debug(
@@ -72,9 +80,23 @@ export const useConnectionStatus = () => {
7280
const connected = await client.checkConnection()
7381
if (!isMounted) return
7482

83+
const prevConnected = previousConnectedRef.current
7584
setIsConnected(connected)
85+
previousConnectedRef.current = connected
7686

7787
if (connected) {
88+
// Determine if this is the initial connection (null) or a reconnection (false)
89+
const isInitialConnection = prevConnected === null
90+
const shouldFireReconnectCallback =
91+
typeof onReconnect === 'function' && prevConnected !== true
92+
93+
if (shouldFireReconnectCallback) {
94+
logger.info(
95+
{ isInitialConnection },
96+
'Reconnection detected, firing onReconnect callback',
97+
)
98+
onReconnect(isInitialConnection)
99+
}
78100
consecutiveSuccesses++
79101
const newInterval = getNextInterval(consecutiveSuccesses)
80102

@@ -94,6 +116,7 @@ export const useConnectionStatus = () => {
94116
scheduleNextCheck(currentInterval)
95117
} else {
96118
// Reset to fast polling on connection failure
119+
previousConnectedRef.current = false
97120
consecutiveSuccesses = 0
98121
currentInterval = HEALTH_CHECK_CONFIG.INITIAL_INTERVAL
99122
logger.debug(
@@ -106,6 +129,7 @@ export const useConnectionStatus = () => {
106129
logger.debug({ error }, 'Connection check failed')
107130
if (isMounted) {
108131
setIsConnected(false)
132+
previousConnectedRef.current = false
109133
consecutiveSuccesses = 0
110134
currentInterval = HEALTH_CHECK_CONFIG.INITIAL_INTERVAL
111135
scheduleNextCheck(currentInterval)

cli/src/utils/status-indicator-state.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type StatusIndicatorState =
88
| { kind: 'retrying' }
99
| { kind: 'waiting' }
1010
| { kind: 'streaming' }
11+
| { kind: 'reconnected' }
1112

1213
export type AuthStatus = 'ok' | 'retrying' | 'unreachable'
1314

@@ -17,6 +18,11 @@ export type StatusIndicatorStateArgs = {
1718
nextCtrlCWillExit: boolean
1819
isConnected: boolean
1920
authStatus?: AuthStatus
21+
/**
22+
* Whether to show a transient "Reconnected" status message.
23+
* This should only be true for a short period after a reconnection event.
24+
*/
25+
showReconnectionMessage?: boolean
2026
}
2127

2228
/**
@@ -39,6 +45,7 @@ export const getStatusIndicatorState = ({
3945
nextCtrlCWillExit,
4046
isConnected,
4147
authStatus = 'ok',
48+
showReconnectionMessage = false,
4249
}: StatusIndicatorStateArgs): StatusIndicatorState => {
4350
if (nextCtrlCWillExit) {
4451
return { kind: 'ctrlC' }
@@ -48,6 +55,11 @@ export const getStatusIndicatorState = ({
4855
return { kind: 'clipboard', message: statusMessage }
4956
}
5057

58+
// Transient reconnection indicator takes precedence over other status
59+
if (showReconnectionMessage) {
60+
return { kind: 'reconnected' }
61+
}
62+
5163
// If we're online but the auth request hit a retryable error and is auto-retrying,
5264
// surface that explicitly to the user.
5365
if (authStatus === 'retrying') {

0 commit comments

Comments
 (0)