Skip to content

Commit f4ec499

Browse files
committed
Add connection status indicator and improve CLI hooks
- Add StatusIndicator component with tests - Improve connection status tracking in use-connection-status hook - Enhance scroll management in use-scroll-management hook - Update send message hook in use-send-message - Update chat.tsx to integrate new components
1 parent c86c535 commit f4ec499

File tree

6 files changed

+210
-30
lines changed

6 files changed

+210
-30
lines changed

cli/src/chat.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -372,10 +372,8 @@ export const Chat = ({
372372
const isWaitingForResponse = streamStatus === 'waiting'
373373
const isStreaming = streamStatus !== 'idle'
374374

375-
const handleTimerEvent = useCallback(
376-
(event: SendMessageTimerEvent) => {},
377-
[agentId],
378-
)
375+
// Timer events are currently tracked but not used for UI updates
376+
// Future: Could be used for analytics or debugging
379377

380378
const { sendMessage, clearMessages } = useSendMessage({
381379
messages,
@@ -401,7 +399,7 @@ export const Chat = ({
401399
mainAgentTimer,
402400
scrollToLatest,
403401
availableWidth: separatorWidth,
404-
onTimerEvent: handleTimerEvent,
402+
onTimerEvent: () => {}, // No-op for now
405403
setHasReceivedPlanResponse,
406404
lastMessageMode,
407405
setLastMessageMode,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, test, expect } from 'bun:test'
2+
3+
import { getStatusIndicatorState } from '../status-indicator'
4+
import type { StatusIndicatorStateArgs } from '../status-indicator'
5+
6+
describe('StatusIndicator state logic', () => {
7+
describe('getStatusIndicatorState', () => {
8+
const baseArgs: StatusIndicatorStateArgs = {
9+
clipboardMessage: null,
10+
streamStatus: 'idle',
11+
nextCtrlCWillExit: false,
12+
isConnected: true,
13+
}
14+
15+
test('returns idle state when no special conditions', () => {
16+
const state = getStatusIndicatorState(baseArgs)
17+
expect(state.kind).toBe('idle')
18+
})
19+
20+
test('returns ctrlC state when nextCtrlCWillExit is true (highest priority)', () => {
21+
const state = getStatusIndicatorState({
22+
...baseArgs,
23+
nextCtrlCWillExit: true,
24+
clipboardMessage: 'Some message',
25+
streamStatus: 'streaming',
26+
isConnected: false,
27+
})
28+
expect(state.kind).toBe('ctrlC')
29+
})
30+
31+
test('returns clipboard state when message exists (second priority)', () => {
32+
const state = getStatusIndicatorState({
33+
...baseArgs,
34+
clipboardMessage: 'Copied to clipboard!',
35+
streamStatus: 'streaming',
36+
isConnected: false,
37+
})
38+
expect(state.kind).toBe('clipboard')
39+
if (state.kind === 'clipboard') {
40+
expect(state.message).toBe('Copied to clipboard!')
41+
}
42+
})
43+
44+
test('returns connecting state when not connected (third priority)', () => {
45+
const state = getStatusIndicatorState({
46+
...baseArgs,
47+
isConnected: false,
48+
streamStatus: 'streaming',
49+
})
50+
expect(state.kind).toBe('connecting')
51+
})
52+
53+
test('returns waiting state when streamStatus is waiting', () => {
54+
const state = getStatusIndicatorState({
55+
...baseArgs,
56+
streamStatus: 'waiting',
57+
})
58+
expect(state.kind).toBe('waiting')
59+
})
60+
61+
test('returns streaming state when streamStatus is streaming', () => {
62+
const state = getStatusIndicatorState({
63+
...baseArgs,
64+
streamStatus: 'streaming',
65+
})
66+
expect(state.kind).toBe('streaming')
67+
})
68+
69+
test('handles empty clipboard message as falsy', () => {
70+
const state = getStatusIndicatorState({
71+
...baseArgs,
72+
clipboardMessage: '',
73+
streamStatus: 'streaming',
74+
})
75+
// Empty string is falsy, should fall through to streaming state
76+
expect(state.kind).toBe('streaming')
77+
})
78+
79+
describe('state priority order', () => {
80+
test('nextCtrlCWillExit beats clipboard', () => {
81+
const state = getStatusIndicatorState({
82+
...baseArgs,
83+
nextCtrlCWillExit: true,
84+
clipboardMessage: 'Test',
85+
})
86+
expect(state.kind).toBe('ctrlC')
87+
})
88+
89+
test('clipboard beats connecting', () => {
90+
const state = getStatusIndicatorState({
91+
...baseArgs,
92+
clipboardMessage: 'Test',
93+
isConnected: false,
94+
})
95+
expect(state.kind).toBe('clipboard')
96+
})
97+
98+
test('connecting beats waiting', () => {
99+
const state = getStatusIndicatorState({
100+
...baseArgs,
101+
isConnected: false,
102+
streamStatus: 'waiting',
103+
})
104+
expect(state.kind).toBe('connecting')
105+
})
106+
107+
test('waiting beats streaming', () => {
108+
const state = getStatusIndicatorState({
109+
...baseArgs,
110+
streamStatus: 'waiting',
111+
})
112+
expect(state.kind).toBe('waiting')
113+
})
114+
115+
test('streaming beats idle', () => {
116+
const state = getStatusIndicatorState({
117+
...baseArgs,
118+
streamStatus: 'streaming',
119+
})
120+
expect(state.kind).toBe('streaming')
121+
})
122+
})
123+
})
124+
})

cli/src/components/status-indicator.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { useTheme } from '../hooks/use-theme'
55
import { formatElapsedTime } from '../utils/format-elapsed-time'
66
import type { StreamStatus } from '../hooks/use-message-queue'
77

8+
// Shimmer animation interval for status text (milliseconds)
9+
const SHIMMER_INTERVAL_MS = 160
10+
811
export type StatusIndicatorState =
912
| { kind: 'idle' }
1013
| { kind: 'clipboard'; message: string }
@@ -20,6 +23,20 @@ export type StatusIndicatorStateArgs = {
2023
isConnected: boolean
2124
}
2225

26+
/**
27+
* Determines the status indicator state based on current context.
28+
*
29+
* State priority (highest to lowest):
30+
* 1. nextCtrlCWillExit - User pressed Ctrl+C once, warn about exit
31+
* 2. clipboardMessage - Temporary feedback for clipboard operations
32+
* 3. connecting - Not connected to backend
33+
* 4. waiting - Waiting for AI response to start
34+
* 5. streaming - AI is actively responding
35+
* 6. idle - No activity
36+
*
37+
* @param args - Context for determining indicator state
38+
* @returns The appropriate state indicator
39+
*/
2340
export const getStatusIndicatorState = ({
2441
clipboardMessage,
2542
streamStatus,
@@ -84,7 +101,7 @@ export const StatusIndicator = ({
84101
return (
85102
<ShimmerText
86103
text="thinking..."
87-
interval={160}
104+
interval={SHIMMER_INTERVAL_MS}
88105
primaryColor={theme.secondary}
89106
/>
90107
)
@@ -94,7 +111,7 @@ export const StatusIndicator = ({
94111
return (
95112
<ShimmerText
96113
text="working..."
97-
interval={160}
114+
interval={SHIMMER_INTERVAL_MS}
98115
primaryColor={theme.secondary}
99116
/>
100117
)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useState } from 'react'
22

33
import { getCodebuffClient } from '../utils/codebuff-client'
4+
import { logger } from '../utils/logger'
45

56
export const useConnectionStatus = () => {
67
const [isConnected, setIsConnected] = useState(true)
@@ -22,7 +23,8 @@ export const useConnectionStatus = () => {
2223
if (isMounted) {
2324
setIsConnected(connected)
2425
}
25-
} catch {
26+
} catch (error) {
27+
logger.debug({ error }, 'Connection check failed')
2628
if (isMounted) {
2729
setIsConnected(false)
2830
}

cli/src/hooks/use-scroll-management.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,29 @@ import { useCallback, useEffect, useRef, useState } from 'react'
22

33
import type { ScrollBoxRenderable } from '@opentui/core'
44

5+
// Scroll detection threshold - how close to bottom to consider "at bottom"
6+
const SCROLL_NEAR_BOTTOM_THRESHOLD = 1
7+
8+
// Animation constants
9+
const ANIMATION_FRAME_INTERVAL_MS = 16 // ~60fps
10+
const DEFAULT_SCROLL_ANIMATION_DURATION_MS = 200
11+
12+
// Delay before auto-scrolling after content changes
13+
const AUTO_SCROLL_DELAY_MS = 50
14+
515
const easeOutCubic = (t: number): number => {
616
return 1 - Math.pow(1 - t, 3)
717
}
818

19+
/**
20+
* Manages scroll behavior for the chat scrollbox with smooth animations and auto-scroll.
21+
*
22+
* @param scrollRef - Reference to the scrollbox component
23+
* @param messages - Array of chat messages (triggers auto-scroll on change)
24+
* @param isUserCollapsing - Callback to check if user is actively collapsing/expanding toggles.
25+
* When true, auto-scroll is temporarily suppressed to prevent jarring UX.
26+
* @returns Scroll management functions and state
27+
*/
928
export const useChatScrollbox = (
1029
scrollRef: React.RefObject<ScrollBoxRenderable | null>,
1130
messages: any[],
@@ -24,7 +43,7 @@ export const useChatScrollbox = (
2443
}, [])
2544

2645
const animateScrollTo = useCallback(
27-
(targetScroll: number, duration = 200) => {
46+
(targetScroll: number, duration = DEFAULT_SCROLL_ANIMATION_DURATION_MS) => {
2847
const scrollbox = scrollRef.current
2948
if (!scrollbox) return
3049

@@ -33,7 +52,7 @@ export const useChatScrollbox = (
3352
const startScroll = scrollbox.scrollTop
3453
const distance = targetScroll - startScroll
3554
const startTime = Date.now()
36-
const frameInterval = 16
55+
const frameInterval = ANIMATION_FRAME_INTERVAL_MS
3756

3857
const animate = () => {
3958
const elapsed = Date.now() - startTime
@@ -79,7 +98,7 @@ export const useChatScrollbox = (
7998
scrollbox.scrollHeight - scrollbox.viewport.height,
8099
)
81100
const current = scrollbox.verticalScrollBar.scrollPosition
82-
const isNearBottom = Math.abs(maxScroll - current) <= 1
101+
const isNearBottom = Math.abs(maxScroll - current) <= SCROLL_NEAR_BOTTOM_THRESHOLD
83102

84103
if (programmaticScrollRef.current) {
85104
programmaticScrollRef.current = false
@@ -116,7 +135,7 @@ export const useChatScrollbox = (
116135
programmaticScrollRef.current = true
117136
scrollbox.scrollTop = maxScroll
118137
}
119-
}, 50)
138+
}, AUTO_SCROLL_DELAY_MS)
120139

121140
return () => clearTimeout(timeoutId)
122141
}

cli/src/hooks/use-send-message.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ const scrubPlanTagsInBlocks = (blocks: ContentBlock[]): ContentBlock[] => {
6868
.filter((b) => b.type !== 'text' || b.content.trim() !== '')
6969
}
7070

71+
/**
72+
* Auto-collapse thinking blocks to reduce UI clutter.
73+
* Tracks which thinking blocks have been collapsed to avoid duplicate collapses.
74+
*
75+
* @param messageId - ID of the message containing the thinking block
76+
* @param agentId - Optional agent ID for nested agent thinking blocks
77+
* @param autoCollapsedThinkingIdsRef - Ref tracking which thinking IDs have been auto-collapsed
78+
* @param setCollapsedAgents - State setter for collapsed agents
79+
*/
80+
const autoCollapseThinkingBlock = (
81+
messageId: string,
82+
agentId: string | undefined,
83+
autoCollapsedThinkingIdsRef: React.MutableRefObject<Set<string>>,
84+
setCollapsedAgents: React.Dispatch<React.SetStateAction<Set<string>>>,
85+
) => {
86+
const thinkingId = agentId
87+
? `${messageId}-agent-${agentId}-thinking-0`
88+
: `${messageId}-thinking-0`
89+
90+
if (!autoCollapsedThinkingIdsRef.current.has(thinkingId)) {
91+
autoCollapsedThinkingIdsRef.current.add(thinkingId)
92+
setCollapsedAgents((prev) => new Set(prev).add(thinkingId))
93+
}
94+
}
95+
7196
export type SendMessageTimerEvent =
7297
| {
7398
type: 'start'
@@ -819,11 +844,12 @@ export const useSendMessage = ({
819844

820845
// Auto-collapse thinking blocks by default (only once per thinking block)
821846
if (eventObj.type === 'reasoning') {
822-
const thinkingId = `${aiMessageId}-thinking-0`
823-
if (!autoCollapsedThinkingIdsRef.current.has(thinkingId)) {
824-
autoCollapsedThinkingIdsRef.current.add(thinkingId)
825-
setCollapsedAgents((prev) => new Set(prev).add(thinkingId))
826-
}
847+
autoCollapseThinkingBlock(
848+
aiMessageId,
849+
undefined,
850+
autoCollapsedThinkingIdsRef,
851+
setCollapsedAgents,
852+
)
827853
}
828854

829855
rootStreamSeenRef.current = true
@@ -892,11 +918,12 @@ export const useSendMessage = ({
892918

893919
// Auto-collapse thinking blocks for subagents on first content
894920
if (previous.length === 0) {
895-
const thinkingId = `${aiMessageId}-agent-${event.agentId}-thinking-0`
896-
if (!autoCollapsedThinkingIdsRef.current.has(thinkingId)) {
897-
autoCollapsedThinkingIdsRef.current.add(thinkingId)
898-
setCollapsedAgents((prev) => new Set(prev).add(thinkingId))
899-
}
921+
autoCollapseThinkingBlock(
922+
aiMessageId,
923+
event.agentId,
924+
autoCollapsedThinkingIdsRef,
925+
setCollapsedAgents,
926+
)
900927
}
901928

902929
updateAgentContent(event.agentId, {
@@ -905,14 +932,7 @@ export const useSendMessage = ({
905932
})
906933
} else {
907934
if (rootStreamSeenRef.current) {
908-
// Disabled noisy log
909-
// logger.info(
910-
// {
911-
// textPreview: text.slice(0, 100),
912-
// textLength: text.length,
913-
// },
914-
// 'Skipping root text event (stream already handled)',
915-
// )
935+
// Skip redundant root text events when stream chunks already handled
916936
return
917937
}
918938
const previous = rootStreamBufferRef.current ?? ''

0 commit comments

Comments
 (0)