Skip to content

Commit c72e7c5

Browse files
committed
refactor(cli): extract connection status hook and add state machine to StatusIndicator
- Extract useConnectionStatus hook into separate file with proper cleanup - Add getStatusIndicatorState function with explicit state machine pattern - Simplify StatusIndicator component logic using state-based rendering - Update tests to use new architecture and test state function directly - Add isConnected prop to StatusIndicator component
1 parent 5dcc740 commit c72e7c5

File tree

4 files changed

+147
-72
lines changed

4 files changed

+147
-72
lines changed

cli/src/chat.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
StatusIndicator,
1414
StatusElapsedTime,
15+
getStatusIndicatorState,
1516
} from './components/status-indicator'
1617
import { SuggestionMenu } from './components/suggestion-menu'
1718
import { SLASH_COMMANDS } from './data/slash-commands'
@@ -32,6 +33,7 @@ import { useSuggestionMenuHandlers } from './hooks/use-suggestion-menu-handlers'
3233
import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
3334
import { useTheme } from './hooks/use-theme'
3435
import { useValidationBanner } from './hooks/use-validation-banner'
36+
import { useConnectionStatus } from './hooks/use-connection-status'
3537
import { useChatStore } from './state/chat-store'
3638
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
3739
import { formatQueuedPreview } from './utils/helpers'
@@ -212,6 +214,7 @@ export const Chat = ({
212214
const sendMessageRef = useRef<SendMessageFn>()
213215

214216
const { clipboardMessage } = useClipboard()
217+
const isConnected = useConnectionStatus()
215218
const mainAgentTimer = useElapsedTime()
216219
const timerStartTime = mainAgentTimer.startTime
217220

@@ -566,19 +569,24 @@ export const Chat = ({
566569
const isMultilineInput = inputLayoutMetrics.heightLines > 1
567570
const shouldCenterInputVertically =
568571
!hasSuggestionMenu && !showAgentStatusLine && !isMultilineInput
572+
const statusIndicatorState = getStatusIndicatorState({
573+
clipboardMessage,
574+
streamStatus,
575+
nextCtrlCWillExit,
576+
isConnected,
577+
})
578+
const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle'
579+
569580
const shouldShowStatusLine =
570-
streamStatus !== 'idle' ||
571-
shouldShowQueuePreview ||
572-
!isAtBottom ||
573-
clipboardMessage != null ||
574-
nextCtrlCWillExit
581+
hasStatusIndicatorContent || shouldShowQueuePreview || !isAtBottom
575582

576583
const statusIndicatorNode = (
577584
<StatusIndicator
578585
clipboardMessage={clipboardMessage}
579586
streamStatus={streamStatus}
580587
timerStartTime={timerStartTime}
581588
nextCtrlCWillExit={nextCtrlCWillExit}
589+
isConnected={isConnected}
582590
/>
583591
)
584592

cli/src/components/__tests__/status-indicator.timer.test.tsx

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,13 @@
1-
import {
2-
describe,
3-
test,
4-
expect,
5-
beforeEach,
6-
afterEach,
7-
mock,
8-
spyOn,
9-
} from 'bun:test'
1+
import { describe, test, expect } from 'bun:test'
102
import React from 'react'
113

124
import { StatusIndicator, StatusElapsedTime } from '../status-indicator'
135

146
import '../../state/theme-store' // Initialize theme store
157
import { renderToStaticMarkup } from 'react-dom/server'
16-
17-
import * as codebuffClient from '../../utils/codebuff-client'
18-
8+
import { getStatusIndicatorState } from '../status-indicator'
199

2010
describe('StatusIndicator state transitions', () => {
21-
let getClientSpy: ReturnType<typeof spyOn>
22-
23-
beforeEach(() => {
24-
getClientSpy = spyOn(codebuffClient, 'getCodebuffClient').mockReturnValue({
25-
checkConnection: mock(async () => true),
26-
} as any)
27-
})
28-
29-
afterEach(() => {
30-
getClientSpy.mockRestore()
31-
})
3211

3312
describe('StatusIndicator text states', () => {
3413
test('shows "thinking..." when waiting for first response (streamStatus = waiting)', () => {
@@ -39,6 +18,7 @@ describe('StatusIndicator state transitions', () => {
3918
streamStatus="waiting"
4019
timerStartTime={now - 5000}
4120
nextCtrlCWillExit={false}
21+
isConnected={true}
4222
/>,
4323
)
4424

@@ -59,6 +39,7 @@ describe('StatusIndicator state transitions', () => {
5939
streamStatus="streaming"
6040
timerStartTime={now - 5000}
6141
nextCtrlCWillExit={false}
42+
isConnected={true}
6243
/>,
6344
)
6445

@@ -76,6 +57,7 @@ describe('StatusIndicator state transitions', () => {
7657
streamStatus="idle"
7758
timerStartTime={null}
7859
nextCtrlCWillExit={false}
60+
isConnected={true}
7961
/>,
8062
)
8163

@@ -92,6 +74,7 @@ describe('StatusIndicator state transitions', () => {
9274
streamStatus="waiting"
9375
timerStartTime={now - 5000}
9476
nextCtrlCWillExit={true}
77+
isConnected={true}
9578
/>,
9679
)
9780

@@ -109,6 +92,7 @@ describe('StatusIndicator state transitions', () => {
10992
streamStatus="waiting"
11093
timerStartTime={now - 12000}
11194
nextCtrlCWillExit={false}
95+
isConnected={true}
11296
/>,
11397
)
11498

@@ -117,6 +101,35 @@ describe('StatusIndicator state transitions', () => {
117101
})
118102
})
119103

104+
describe('Connectivity states', () => {
105+
test('shows "connecting..." shimmer when offline and idle', () => {
106+
const markup = renderToStaticMarkup(
107+
<StatusIndicator
108+
clipboardMessage={null}
109+
streamStatus="idle"
110+
timerStartTime={null}
111+
nextCtrlCWillExit={false}
112+
isConnected={false}
113+
/>,
114+
)
115+
116+
expect(markup).toContain('c')
117+
expect(markup).toContain('o')
118+
expect(markup).toContain('n')
119+
})
120+
121+
test('getStatusIndicatorState reports connecting state when offline', () => {
122+
const state = getStatusIndicatorState({
123+
clipboardMessage: null,
124+
streamStatus: 'idle',
125+
nextCtrlCWillExit: false,
126+
isConnected: false,
127+
})
128+
129+
expect(state.kind).toBe('connecting')
130+
})
131+
})
132+
120133
describe('StatusElapsedTime', () => {
121134
test('shows nothing initially (useEffect not triggered in static render)', () => {
122135
const now = Date.now()

cli/src/components/status-indicator.tsx

Lines changed: 56 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,72 +2,85 @@ import React, { useEffect, useState } from 'react'
22

33
import { ShimmerText } from './shimmer-text'
44
import { useTheme } from '../hooks/use-theme'
5-
import { getCodebuffClient } from '../utils/codebuff-client'
65
import { formatElapsedTime } from '../utils/format-elapsed-time'
76
import type { StreamStatus } from '../hooks/use-message-queue'
87

9-
const useConnectionStatus = () => {
10-
const [isConnected, setIsConnected] = useState(true)
8+
export type StatusIndicatorState =
9+
| { kind: 'idle' }
10+
| { kind: 'clipboard'; message: string }
11+
| { kind: 'ctrlC' }
12+
| { kind: 'connecting' }
13+
| { kind: 'waiting' }
14+
| { kind: 'streaming' }
1115

12-
useEffect(() => {
13-
const checkConnection = async () => {
14-
const client = getCodebuffClient()
15-
if (!client) {
16-
setIsConnected(false)
17-
return
18-
}
19-
20-
try {
21-
const connected = await client.checkConnection()
22-
setIsConnected(connected)
23-
} catch (error) {
24-
setIsConnected(false)
25-
}
26-
}
16+
export type StatusIndicatorStateArgs = {
17+
clipboardMessage?: string | null
18+
streamStatus: StreamStatus
19+
nextCtrlCWillExit: boolean
20+
isConnected: boolean
21+
}
22+
23+
export const getStatusIndicatorState = ({
24+
clipboardMessage,
25+
streamStatus,
26+
nextCtrlCWillExit,
27+
isConnected,
28+
}: StatusIndicatorStateArgs): StatusIndicatorState => {
29+
if (nextCtrlCWillExit) {
30+
return { kind: 'ctrlC' }
31+
}
32+
33+
if (clipboardMessage) {
34+
return { kind: 'clipboard', message: clipboardMessage }
35+
}
2736

28-
checkConnection()
37+
if (!isConnected) {
38+
return { kind: 'connecting' }
39+
}
2940

30-
const interval = setInterval(checkConnection, 30000)
41+
if (streamStatus === 'waiting') {
42+
return { kind: 'waiting' }
43+
}
3144

32-
return () => clearInterval(interval)
33-
}, [])
45+
if (streamStatus === 'streaming') {
46+
return { kind: 'streaming' }
47+
}
3448

35-
return isConnected
49+
return { kind: 'idle' }
50+
}
51+
52+
type StatusIndicatorProps = StatusIndicatorStateArgs & {
53+
timerStartTime: number | null
3654
}
3755

3856
export const StatusIndicator = ({
3957
clipboardMessage,
4058
streamStatus,
4159
timerStartTime,
4260
nextCtrlCWillExit,
43-
}: {
44-
clipboardMessage?: string | null
45-
streamStatus: StreamStatus
46-
timerStartTime: number | null
47-
nextCtrlCWillExit: boolean
48-
}) => {
61+
isConnected,
62+
}: StatusIndicatorProps) => {
4963
const theme = useTheme()
50-
const isConnected = useConnectionStatus()
51-
52-
if (nextCtrlCWillExit) {
64+
const state = getStatusIndicatorState({
65+
clipboardMessage,
66+
streamStatus,
67+
nextCtrlCWillExit,
68+
isConnected,
69+
})
70+
71+
if (state.kind === 'ctrlC') {
5372
return <span fg={theme.secondary}>Press Ctrl-C again to exit</span>
5473
}
5574

56-
if (clipboardMessage) {
57-
return <span fg={theme.primary}>{clipboardMessage}</span>
58-
}
59-
60-
const hasStatus = isConnected === false || streamStatus !== 'idle'
61-
62-
if (!hasStatus) {
63-
return null
75+
if (state.kind === 'clipboard') {
76+
return <span fg={theme.primary}>{state.message}</span>
6477
}
6578

66-
if (isConnected === false) {
79+
if (state.kind === 'connecting') {
6780
return <ShimmerText text="connecting..." />
6881
}
6982

70-
if (streamStatus === 'waiting') {
83+
if (state.kind === 'waiting') {
7184
return (
7285
<ShimmerText
7386
text="thinking..."
@@ -77,7 +90,7 @@ export const StatusIndicator = ({
7790
)
7891
}
7992

80-
if (streamStatus === 'streaming') {
93+
if (state.kind === 'streaming') {
8194
return (
8295
<ShimmerText
8396
text="working..."
@@ -126,4 +139,3 @@ export const StatusElapsedTime = ({
126139

127140
return <span fg={theme.secondary}>{formatElapsedTime(elapsedSeconds)}</span>
128141
}
129-
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useEffect, useState } from 'react'
2+
3+
import { getCodebuffClient } from '../utils/codebuff-client'
4+
5+
export const useConnectionStatus = () => {
6+
const [isConnected, setIsConnected] = useState(true)
7+
8+
useEffect(() => {
9+
let isMounted = true
10+
11+
const checkConnection = async () => {
12+
const client = getCodebuffClient()
13+
if (!client) {
14+
if (isMounted) {
15+
setIsConnected(false)
16+
}
17+
return
18+
}
19+
20+
try {
21+
const connected = await client.checkConnection()
22+
if (isMounted) {
23+
setIsConnected(connected)
24+
}
25+
} catch {
26+
if (isMounted) {
27+
setIsConnected(false)
28+
}
29+
}
30+
}
31+
32+
checkConnection()
33+
const interval = setInterval(checkConnection, 30000)
34+
35+
return () => {
36+
isMounted = false
37+
clearInterval(interval)
38+
}
39+
}, [])
40+
41+
return isConnected
42+
}

0 commit comments

Comments
 (0)