Skip to content

Commit 1e79031

Browse files
committed
Add timer UI tests and structured helper coverage
1 parent c1805f7 commit 1e79031

File tree

12 files changed

+536
-379
lines changed

12 files changed

+536
-379
lines changed

bun.lock

Lines changed: 48 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@types/node": "22",
5858
"@types/react": "^18.3.12",
5959
"@types/react-reconciler": "^0.32.0",
60+
"react-dom": "^19.0.0",
6061
"strip-ansi": "^7.1.2"
6162
}
6263
}

cli/src/chat.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useMessageQueue } from './hooks/use-message-queue'
3535
import { useMessageRenderer } from './hooks/use-message-renderer'
3636
import { useChatScrollbox } from './hooks/use-scroll-management'
3737
import { useSendMessage } from './hooks/use-send-message'
38+
import type { SendMessageTimerEvent } from './hooks/use-send-message'
3839
import { useSuggestionEngine } from './hooks/use-suggestion-engine'
3940
import { useSystemThemeDetector } from './hooks/use-system-theme-detector'
4041
import { useChatStore } from './state/chat-store'
@@ -813,6 +814,31 @@ export const App = ({
813814
activeAgentStreamsRef,
814815
)
815816

817+
const handleTimerEvent = useCallback(
818+
(event: SendMessageTimerEvent) => {
819+
const payload = {
820+
event: 'cli_main_agent_timer',
821+
timerEventType: event.type,
822+
agentId: agentId ?? 'main',
823+
messageId: event.messageId,
824+
startedAt: event.startedAt,
825+
...(event.type === 'stop'
826+
? {
827+
finishedAt: event.finishedAt,
828+
elapsedMs: event.elapsedMs,
829+
outcome: event.outcome,
830+
}
831+
: {}),
832+
}
833+
const message =
834+
event.type === 'start'
835+
? 'Main agent timer started'
836+
: `Main agent timer stopped (${event.outcome})`
837+
logger.info(payload, message)
838+
},
839+
[agentId],
840+
)
841+
816842
const { sendMessage } = useSendMessage({
817843
setMessages,
818844
setFocusedAgentId,
@@ -835,6 +861,7 @@ export const App = ({
835861
mainAgentTimer,
836862
scrollToLatest,
837863
availableWidth: separatorWidth,
864+
onTimerEvent: handleTimerEvent,
838865
})
839866

840867
sendMessageRef.current = sendMessage
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React from 'react'
2+
3+
import { describe, test, expect } from 'bun:test'
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
6+
import { MessageBlock } from '../message-block'
7+
import { chatThemes, createMarkdownPalette } from '../../utils/theme-system'
8+
import type { MarkdownPalette } from '../../utils/markdown-renderer'
9+
10+
const theme = chatThemes.dark
11+
12+
const basePalette = createMarkdownPalette(theme)
13+
14+
const palette: MarkdownPalette = {
15+
...basePalette,
16+
inlineCodeFg: theme.messageAiText,
17+
codeTextFg: theme.messageAiText,
18+
}
19+
20+
const baseProps = {
21+
messageId: 'ai-1',
22+
blocks: undefined,
23+
content: 'Hello there',
24+
isUser: false,
25+
isAi: true,
26+
isLoading: false,
27+
timestamp: '12:00',
28+
isComplete: false,
29+
completionTime: undefined,
30+
credits: undefined,
31+
timer: {
32+
start: () => {},
33+
stop: () => {},
34+
elapsedSeconds: 0,
35+
startTime: null,
36+
},
37+
theme,
38+
textColor: theme.messageAiText,
39+
timestampColor: theme.timestampAi,
40+
markdownOptions: {
41+
codeBlockWidth: 72,
42+
palette,
43+
},
44+
availableWidth: 80,
45+
markdownPalette: basePalette,
46+
collapsedAgents: new Set<string>(),
47+
streamingAgents: new Set<string>(),
48+
onToggleCollapsed: () => {},
49+
registerAgentRef: () => {},
50+
}
51+
52+
describe('MessageBlock completion time', () => {
53+
test('renders completion time and credits when complete', () => {
54+
const markup = renderToStaticMarkup(
55+
<MessageBlock
56+
{...baseProps}
57+
isComplete={true}
58+
completionTime="7s"
59+
credits={3}
60+
/>,
61+
)
62+
63+
expect(markup).toContain('7s')
64+
expect(markup).toContain('3 credits')
65+
})
66+
67+
test('omits completion line when not complete', () => {
68+
const markup = renderToStaticMarkup(
69+
<MessageBlock
70+
{...baseProps}
71+
isComplete={false}
72+
completionTime="7s"
73+
credits={3}
74+
/>,
75+
)
76+
77+
expect(markup).not.toContain('7s')
78+
expect(markup).not.toContain('3 credits')
79+
})
80+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from 'react'
2+
3+
import { describe, test, expect } from 'bun:test'
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
6+
import { MessageBlock } from '../message-block'
7+
import { chatThemes, createMarkdownPalette } from '../../utils/theme-system'
8+
import type { MarkdownPalette } from '../../utils/markdown-renderer'
9+
10+
const theme = chatThemes.dark
11+
12+
const basePalette = createMarkdownPalette(theme)
13+
14+
const palette: MarkdownPalette = {
15+
...basePalette,
16+
inlineCodeFg: theme.messageAiText,
17+
codeTextFg: theme.messageAiText,
18+
}
19+
20+
const baseProps = {
21+
messageId: 'ai-stream',
22+
blocks: undefined,
23+
content: 'Streaming response...',
24+
isUser: false,
25+
isAi: true,
26+
isComplete: false,
27+
timestamp: '12:00',
28+
completionTime: undefined,
29+
credits: undefined,
30+
theme,
31+
textColor: theme.messageAiText,
32+
timestampColor: theme.timestampAi,
33+
markdownOptions: {
34+
codeBlockWidth: 72,
35+
palette,
36+
},
37+
availableWidth: 80,
38+
markdownPalette: basePalette,
39+
collapsedAgents: new Set<string>(),
40+
streamingAgents: new Set<string>(),
41+
onToggleCollapsed: () => {},
42+
registerAgentRef: () => {},
43+
}
44+
45+
const createTimer = (elapsedSeconds: number) => ({
46+
start: () => {},
47+
stop: () => {},
48+
elapsedSeconds,
49+
startTime: elapsedSeconds > 0 ? Date.now() - elapsedSeconds * 1000 : null,
50+
})
51+
52+
describe('MessageBlock streaming indicator', () => {
53+
test('shows elapsed seconds while streaming', () => {
54+
const markup = renderToStaticMarkup(
55+
<MessageBlock
56+
{...baseProps}
57+
isLoading={true}
58+
timer={createTimer(4)}
59+
/>,
60+
)
61+
62+
expect(markup).toContain('4s')
63+
})
64+
65+
test('hides elapsed seconds when timer has not advanced', () => {
66+
const markup = renderToStaticMarkup(
67+
<MessageBlock
68+
{...baseProps}
69+
isLoading={true}
70+
timer={createTimer(0)}
71+
/>,
72+
)
73+
74+
expect(markup).not.toContain('0s')
75+
})
76+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, test, expect } from 'bun:test'
2+
3+
import { computeHasStatus } from '../status-indicator'
4+
5+
const createTimer = (startTime: number | null) => ({
6+
start: () => {},
7+
stop: () => {},
8+
elapsedSeconds: startTime ? Math.floor((Date.now() - startTime) / 1000) : 0,
9+
startTime,
10+
})
11+
12+
describe('computeHasStatus', () => {
13+
test('returns true when connection is lost', () => {
14+
expect(
15+
computeHasStatus({
16+
isConnected: false,
17+
isActive: false,
18+
clipboardMessage: null,
19+
timer: createTimer(null),
20+
}),
21+
).toBe(true)
22+
})
23+
24+
test('returns true when active', () => {
25+
expect(
26+
computeHasStatus({
27+
isConnected: true,
28+
isActive: true,
29+
clipboardMessage: null,
30+
timer: createTimer(null),
31+
}),
32+
).toBe(true)
33+
})
34+
35+
test('returns true when clipboard message exists', () => {
36+
expect(
37+
computeHasStatus({
38+
isConnected: true,
39+
isActive: false,
40+
clipboardMessage: 'Copied!',
41+
timer: createTimer(null),
42+
}),
43+
).toBe(true)
44+
})
45+
46+
test('returns true when timer has started', () => {
47+
expect(
48+
computeHasStatus({
49+
isConnected: true,
50+
isActive: false,
51+
clipboardMessage: null,
52+
timer: createTimer(Date.now() - 5000),
53+
}),
54+
).toBe(true)
55+
})
56+
57+
test('returns false when idle, connected, and timer inactive', () => {
58+
expect(
59+
computeHasStatus({
60+
isConnected: true,
61+
isActive: false,
62+
clipboardMessage: null,
63+
timer: createTimer(null),
64+
}),
65+
).toBe(false)
66+
})
67+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react'
2+
3+
import {
4+
describe,
5+
test,
6+
expect,
7+
beforeAll,
8+
afterAll,
9+
beforeEach,
10+
afterEach,
11+
mock,
12+
spyOn,
13+
} from 'bun:test'
14+
15+
import { StatusIndicator } from '../status-indicator'
16+
import { chatThemes } from '../../utils/theme-system'
17+
import { renderToStaticMarkup } from 'react-dom/server'
18+
import * as codebuffClient from '../../utils/codebuff-client'
19+
20+
const theme = chatThemes.dark
21+
22+
const createTimer = (elapsedSeconds: number, started: boolean) => ({
23+
start: () => {},
24+
stop: () => {},
25+
elapsedSeconds,
26+
startTime: started ? Date.now() - elapsedSeconds * 1000 : null,
27+
})
28+
29+
describe('StatusIndicator timer rendering', () => {
30+
let getClientSpy: ReturnType<typeof spyOn>
31+
32+
beforeEach(() => {
33+
getClientSpy = spyOn(codebuffClient, 'getCodebuffClient').mockReturnValue({
34+
checkConnection: mock(async () => true),
35+
} as any)
36+
})
37+
38+
afterEach(() => {
39+
getClientSpy.mockRestore()
40+
})
41+
42+
test('shows elapsed seconds when timer is active', () => {
43+
const markup = renderToStaticMarkup(
44+
<StatusIndicator
45+
theme={theme}
46+
clipboardMessage={null}
47+
isActive={true}
48+
timer={createTimer(5, true)}
49+
/>,
50+
)
51+
52+
expect(markup).toContain('5s')
53+
54+
const inactiveMarkup = renderToStaticMarkup(
55+
<StatusIndicator
56+
theme={theme}
57+
clipboardMessage={null}
58+
isActive={false}
59+
timer={createTimer(0, false)}
60+
/>,
61+
)
62+
63+
expect(inactiveMarkup).toBe('')
64+
})
65+
66+
test('clipboard message takes priority over timer output', () => {
67+
const markup = renderToStaticMarkup(
68+
<StatusIndicator
69+
theme={theme}
70+
clipboardMessage="Copied!"
71+
isActive={true}
72+
timer={createTimer(12, true)}
73+
/>,
74+
)
75+
76+
expect(markup).toContain('Copied!')
77+
expect(markup).not.toContain('12s')
78+
})
79+
})

cli/src/components/status-indicator.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,29 @@ export const useHasStatus = (
8989
timer?: ElapsedTimeTracker,
9090
): boolean => {
9191
const isConnected = useConnectionStatus()
92+
return computeHasStatus({
93+
isConnected,
94+
isActive,
95+
clipboardMessage,
96+
timer,
97+
})
98+
}
99+
100+
export const computeHasStatus = ({
101+
isConnected,
102+
isActive,
103+
clipboardMessage,
104+
timer,
105+
}: {
106+
isConnected: boolean
107+
isActive: boolean
108+
clipboardMessage?: string | null
109+
timer?: ElapsedTimeTracker
110+
}): boolean => {
92111
return (
93-
isConnected === false || isActive || !!clipboardMessage || !!timer?.startTime
112+
isConnected === false ||
113+
isActive ||
114+
Boolean(clipboardMessage) ||
115+
Boolean(timer?.startTime)
94116
)
95117
}

0 commit comments

Comments
 (0)