Skip to content

Commit c13f042

Browse files
committed
feat: improve connection handling with automatic retry and reconnection detection
- Add reconnection callback to detect and handle connection restoration - Implement batch retry processing for messages that failed due to connection - Add stream stall detection with 4-second timeout after initial output - Refactor StatusBar to use unified statusIndicatorState pattern - Display temporary reconnection message on connection restoration - Track connection state transitions for proper retry triggering - Add extensive logging for connection and retry flow debugging
1 parent 3073ea5 commit c13f042

File tree

5 files changed

+498
-108
lines changed

5 files changed

+498
-108
lines changed

cli/src/chat.tsx

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { useQueueControls } from './hooks/use-queue-controls'
3434
import { useChatStore } from './state/chat-store'
3535
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
3636
import { loadLocalAgents } from './utils/local-agent-registry'
37+
import { logger } from './utils/logger'
3738
import { buildMessageTree } from './utils/message-tree-utils'
3839
import { computeInputLayoutMetrics } from './utils/text-layout'
3940
import { createMarkdownPalette } from './utils/theme-system'
@@ -192,11 +193,57 @@ export const Chat = ({
192193
const sendMessageRef = useRef<SendMessageFn>()
193194

194195
const { clipboardMessage } = useClipboard()
195-
const isConnected = useConnectionStatus()
196+
const [showReconnectionMessage, setShowReconnectionMessage] = useState(false)
197+
const [connectionEstablished, setConnectionEstablished] = useState(0) // Increment to trigger retry check
198+
const reconnectionTimeoutRef = useRef<NodeJS.Timeout | null>(null)
199+
const retryPendingMessagesRef = useRef<(() => Promise<void>) | null>(null)
200+
const processFailedMessagesRef = useRef<(() => void) | null>(null)
201+
202+
const handleReconnection = useCallback((isInitialConnection: boolean) => {
203+
logger.info(
204+
{ isInitialConnection },
205+
`[Connection] ${isInitialConnection ? 'Initial connection' : 'Reconnection'} callback triggered`
206+
)
207+
208+
// Process any failed messages and schedule them for retry (batched)
209+
if (processFailedMessagesRef.current) {
210+
processFailedMessagesRef.current()
211+
}
212+
213+
// Only show reconnection message if it's not the initial connection
214+
if (!isInitialConnection) {
215+
setShowReconnectionMessage(true)
216+
217+
// Clear any existing timeout
218+
if (reconnectionTimeoutRef.current) {
219+
clearTimeout(reconnectionTimeoutRef.current)
220+
}
221+
222+
// Hide the message after 2 seconds
223+
reconnectionTimeoutRef.current = setTimeout(() => {
224+
setShowReconnectionMessage(false)
225+
reconnectionTimeoutRef.current = null
226+
}, 2000)
227+
}
228+
229+
// Always trigger retry check (for both initial connection and reconnection)
230+
setConnectionEstablished(prev => prev + 1)
231+
}, [])
232+
233+
const isConnected = useConnectionStatus(handleReconnection)
196234
const isConnectedRef = useRef(isConnected)
197235
useEffect(() => {
198236
isConnectedRef.current = isConnected
199237
}, [isConnected])
238+
239+
useEffect(() => {
240+
return () => {
241+
if (reconnectionTimeoutRef.current) {
242+
clearTimeout(reconnectionTimeoutRef.current)
243+
}
244+
}
245+
}, [])
246+
200247
const mainAgentTimer = useElapsedTime()
201248
const timerStartTime = mainAgentTimer.startTime
202249

@@ -397,8 +444,13 @@ export const Chat = ({
397444
// Timer events are currently tracked but not used for UI updates
398445
// Future: Could be used for analytics or debugging
399446

400-
const { sendMessage, clearMessages, pendingRetryCount, retryPendingMessages } =
401-
useSendMessage({
447+
const {
448+
sendMessage,
449+
clearMessages,
450+
pendingRetryCount,
451+
retryPendingMessages,
452+
processFailedMessages,
453+
} = useSendMessage({
402454
messages,
403455
allToggleIds,
404456
setMessages,
@@ -433,6 +485,27 @@ export const Chat = ({
433485
})
434486

435487
sendMessageRef.current = sendMessage
488+
retryPendingMessagesRef.current = retryPendingMessages
489+
processFailedMessagesRef.current = processFailedMessages
490+
491+
// Trigger retry when connection is established and we have pending messages
492+
useEffect(() => {
493+
if (connectionEstablished > 0 && pendingRetryCount > 0) {
494+
logger.info(
495+
{ pendingRetryCount, connectionEstablished },
496+
`[RETRY-EFFECT] CONDITIONS MET! Will retry ${pendingRetryCount} pending message(s) after 500ms delay...`
497+
)
498+
// Small delay to ensure the connection is fully established
499+
const timer = setTimeout(() => {
500+
logger.info('[RETRY-EFFECT] Calling retryPendingMessages()')
501+
retryPendingMessagesRef.current?.()
502+
}, 500)
503+
return () => {
504+
clearTimeout(timer)
505+
}
506+
}
507+
return undefined
508+
}, [connectionEstablished, pendingRetryCount])
436509

437510
const { inputWidth, handleBuildFast, handleBuildMax } = useChatInput({
438511
inputValue,
@@ -593,6 +666,7 @@ export const Chat = ({
593666
streamStatus,
594667
nextCtrlCWillExit,
595668
isConnected,
669+
showReconnectionMessage,
596670
})
597671
const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle'
598672
const inputBoxTitle = useMemo(() => {
@@ -705,13 +779,12 @@ export const Chat = ({
705779
<StatusBar
706780
clipboardMessage={clipboardMessage}
707781
streamStatus={streamStatus}
782+
statusIndicatorState={statusIndicatorState}
708783
timerStartTime={timerStartTime}
709784
nextCtrlCWillExit={nextCtrlCWillExit}
710785
isConnected={isConnected}
711786
isAtBottom={isAtBottom}
712787
scrollToLatest={scrollToLatest}
713-
pendingRetryCount={pendingRetryCount}
714-
retryPendingMessages={retryPendingMessages}
715788
/>
716789
)}
717790

cli/src/components/status-bar.tsx

Lines changed: 47 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,34 @@ import React, { useEffect, useState } from 'react'
22

33
import { ShimmerText } from './shimmer-text'
44
import { ScrollToBottomButton } from './scroll-to-bottom-button'
5-
import { Button } from './button'
65
import { useTheme } from '../hooks/use-theme'
76
import { formatElapsedTime } from '../utils/format-elapsed-time'
87

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

1111
const SHIMMER_INTERVAL_MS = 160
1212

1313
interface StatusBarProps {
1414
clipboardMessage: string | null
1515
streamStatus: StreamStatus
16+
statusIndicatorState: StatusIndicatorState
1617
timerStartTime: number | null
1718
nextCtrlCWillExit: boolean
1819
isConnected: boolean
1920
isAtBottom: boolean
2021
scrollToLatest: () => void
21-
pendingRetryCount: number
22-
retryPendingMessages: () => Promise<void>
2322
}
2423

2524
export const StatusBar = ({
2625
clipboardMessage,
2726
streamStatus,
27+
statusIndicatorState,
2828
timerStartTime,
2929
nextCtrlCWillExit,
3030
isConnected,
3131
isAtBottom,
3232
scrollToLatest,
33-
pendingRetryCount,
34-
retryPendingMessages,
3533
}: StatusBarProps) => {
3634
const theme = useTheme()
3735
const [elapsedSeconds, setElapsedSeconds] = useState(0)
@@ -65,43 +63,49 @@ export const StatusBar = ({
6563
)
6664

6765
const renderStatusIndicator = () => {
68-
if (nextCtrlCWillExit) {
69-
return (
70-
<text fg={theme.secondary} style={{ wrapMode: 'none' }}>
71-
Press Ctrl-C again to exit
72-
</text>
73-
)
66+
switch (statusIndicatorState.kind) {
67+
case 'ctrlC':
68+
return (
69+
<text fg={theme.secondary} style={{ wrapMode: 'none' }}>
70+
Press Ctrl-C again to exit
71+
</text>
72+
)
73+
74+
case 'reconnected':
75+
return (
76+
<text fg={theme.success || theme.primary} style={{ wrapMode: 'none' }}>
77+
✓ Reconnected
78+
</text>
79+
)
80+
81+
case 'clipboard':
82+
return (
83+
<text fg={theme.primary} style={{ wrapMode: 'none' }}>
84+
{statusIndicatorState.message}
85+
</text>
86+
)
87+
88+
case 'connecting':
89+
return renderShimmer({ text: 'connecting...' })
90+
91+
case 'waiting':
92+
return renderShimmer({
93+
text: 'thinking...',
94+
interval: SHIMMER_INTERVAL_MS,
95+
primaryColor: theme.secondary,
96+
})
97+
98+
case 'streaming':
99+
return renderShimmer({
100+
text: 'working...',
101+
interval: SHIMMER_INTERVAL_MS,
102+
primaryColor: theme.secondary,
103+
})
104+
105+
case 'idle':
106+
default:
107+
return null
74108
}
75-
76-
if (clipboardMessage) {
77-
return (
78-
<text fg={theme.primary} style={{ wrapMode: 'none' }}>
79-
{clipboardMessage}
80-
</text>
81-
)
82-
}
83-
84-
if (!isConnected) {
85-
return renderShimmer({ text: 'connecting...' })
86-
}
87-
88-
if (streamStatus === 'waiting') {
89-
return renderShimmer({
90-
text: 'thinking...',
91-
interval: SHIMMER_INTERVAL_MS,
92-
primaryColor: theme.secondary,
93-
})
94-
}
95-
96-
if (streamStatus === 'streaming') {
97-
return renderShimmer({
98-
text: 'working...',
99-
interval: SHIMMER_INTERVAL_MS,
100-
primaryColor: theme.secondary,
101-
})
102-
}
103-
104-
return null
105109
}
106110

107111
const renderElapsedTime = () => {
@@ -118,44 +122,9 @@ export const StatusBar = ({
118122

119123
const statusIndicatorContent = renderStatusIndicator()
120124
const elapsedTimeContent = renderElapsedTime()
121-
const hasPendingRetries = pendingRetryCount > 0
122-
const pendingRetryMessage =
123-
pendingRetryCount === 1
124-
? 'Message send interrupted'
125-
: `${pendingRetryCount} messages interrupted`
126-
const handleRetryClick = () => {
127-
void retryPendingMessages()
128-
}
129-
const pendingRetryContent = hasPendingRetries ? (
130-
<box
131-
style={{
132-
flexDirection: 'row',
133-
alignItems: 'center',
134-
gap: 1,
135-
}}
136-
>
137-
<text fg={theme.primary} style={{ wrapMode: 'none' }}>
138-
{pendingRetryMessage}
139-
</text>
140-
<Button
141-
style={{
142-
borderStyle: 'round',
143-
borderColor: theme.primary,
144-
paddingLeft: 1,
145-
paddingRight: 1,
146-
}}
147-
onClick={handleRetryClick}
148-
>
149-
<text fg={theme.primary} style={{ wrapMode: 'none' }}>
150-
Retry now
151-
</text>
152-
</Button>
153-
</box>
154-
) : null
155125

156126
// Only show gray background when there's status indicator or timer content
157-
const hasContent =
158-
hasPendingRetries || statusIndicatorContent || elapsedTimeContent
127+
const hasContent = statusIndicatorContent || elapsedTimeContent
159128

160129
return (
161130
<box
@@ -176,7 +145,7 @@ export const StatusBar = ({
176145
flexBasis: 0,
177146
}}
178147
>
179-
{pendingRetryContent ?? statusIndicatorContent}
148+
{statusIndicatorContent}
180149
</box>
181150

182151
<box style={{ flexShrink: 0 }}>

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,20 @@ export function getNextInterval(consecutiveSuccesses: number): number {
3737
* Hook to monitor connection status to the Codebuff backend.
3838
* Uses adaptive exponential backoff to reduce polling frequency when connection is stable.
3939
*/
40-
export const useConnectionStatus = () => {
40+
export const useConnectionStatus = (
41+
onReconnected?: (isInitialConnection: boolean) => void,
42+
) => {
4143

42-
const [isConnected, setIsConnected] = useState(true)
44+
// Start with null to indicate unknown state
45+
const [isConnected, setIsConnected] = useState<boolean | null>(null)
4346

4447
useEffect(() => {
4548
let isMounted = true
4649
let timeoutId: NodeJS.Timeout | null = null
4750
let consecutiveSuccesses = 0
4851
let currentInterval: number = HEALTH_CHECK_CONFIG.INITIAL_INTERVAL
52+
// Start with null to properly detect first connection
53+
let previousConnected: boolean | null = null
4954

5055
const scheduleNextCheck = (interval: number) => {
5156
if (!isMounted) return
@@ -56,6 +61,7 @@ export const useConnectionStatus = () => {
5661
const client = getCodebuffClient()
5762
if (!client) {
5863
if (isMounted) {
64+
previousConnected = false
5965
setIsConnected(false)
6066
consecutiveSuccesses = 0
6167
currentInterval = HEALTH_CHECK_CONFIG.INITIAL_INTERVAL
@@ -72,6 +78,33 @@ export const useConnectionStatus = () => {
7278
const connected = await client.checkConnection()
7379
if (!isMounted) return
7480

81+
// Detect reconnection (was disconnected/unknown, now connected)
82+
// Also handle first connection (for pending messages from previous session)
83+
if (connected && (previousConnected === false || previousConnected === null)) {
84+
const isInitialConnection = previousConnected === null
85+
logger.info(
86+
{
87+
previousConnected,
88+
connected,
89+
isInitialConnection,
90+
hasCallback: !!onReconnected
91+
},
92+
isInitialConnection
93+
? '[CONNECTION-CHECK] Initial connection established - checking for pending messages'
94+
: '[CONNECTION-CHECK] Connection restored to backend - triggering reconnection handler'
95+
)
96+
if (onReconnected) {
97+
logger.info(
98+
{ isInitialConnection },
99+
'[CONNECTION-CHECK] Calling onReconnected callback'
100+
)
101+
onReconnected(isInitialConnection)
102+
}
103+
} else if (!connected && previousConnected === true) {
104+
logger.info('[CONNECTION-CHECK] Connection lost!')
105+
}
106+
107+
previousConnected = connected
75108
setIsConnected(connected)
76109

77110
if (connected) {
@@ -105,6 +138,7 @@ export const useConnectionStatus = () => {
105138
} catch (error) {
106139
logger.debug({ error }, 'Connection check failed')
107140
if (isMounted) {
141+
previousConnected = false
108142
setIsConnected(false)
109143
consecutiveSuccesses = 0
110144
currentInterval = HEALTH_CHECK_CONFIG.INITIAL_INTERVAL
@@ -122,7 +156,8 @@ export const useConnectionStatus = () => {
122156
clearTimeout(timeoutId)
123157
}
124158
}
125-
}, [])
159+
}, [onReconnected])
126160

127-
return isConnected
161+
// Return false while checking initial connection status
162+
return isConnected ?? false
128163
}

0 commit comments

Comments
 (0)