Skip to content

Commit 1f6ba1d

Browse files
Ampampcode-com
andcommitted
Add ThinkingSpinner component to webview
- Create ThinkingSpinner component for webview with animated Braille frames - Update ProgressIndicator to support optional animated spinner display - Integrate ThinkingSpinner into ChatRow for API requests, commands, and MCP server responses - Add animated spinner to BrowserSessionRow when browsing - Add comprehensive tests for ThinkingSpinner component Amp-Thread-ID: https://ampcode.com/threads/T-134f70aa-d045-4045-9327-599c050b929b Co-authored-by: Amp <amp@ampcode.com>
1 parent 5cca57d commit 1f6ba1d

File tree

5 files changed

+140
-17
lines changed

5 files changed

+140
-17
lines changed

webview-ui/src/components/chat/BrowserSessionRow.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,16 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
235235
? latestClickPosition || displayState.mousePosition
236236
: displayState.mousePosition || defaultMousePosition
237237

238+
const { isStreaming } = props
239+
238240
const [browserSessionRow, { height: rowHeight }] = useSize(
239241
<div style={{ padding: "10px 6px 10px 15px", marginBottom: -10 }}>
240242
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px" }}>
241-
{isBrowsing ? <ProgressIndicator /> : <Pointer className="w-4" aria-label="Browser action indicator" />}
243+
{isBrowsing ? (
244+
<ProgressIndicator useSpinner={isStreaming} />
245+
) : (
246+
<Pointer className="w-4" aria-label="Browser action indicator" />
247+
)}
242248
<span style={{ fontWeight: "bold" }}>
243249
<>{t("chat:browser.rooWantsToUse")}</>
244250
</span>

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ export const ChatRowContent = ({
267267
case "command":
268268
return [
269269
isCommandExecuting ? (
270-
<ProgressIndicator />
270+
<ProgressIndicator useSpinner={isStreaming} />
271271
) : (
272272
<TerminalSquare className="size-4" aria-label="Terminal icon" />
273273
),
@@ -282,7 +282,7 @@ export const ChatRowContent = ({
282282
}
283283
return [
284284
isMcpServerResponding ? (
285-
<ProgressIndicator />
285+
<ProgressIndicator useSpinner={isStreaming} />
286286
) : (
287287
<span
288288
className="codicon codicon-server"
@@ -331,7 +331,7 @@ export const ChatRowContent = ({
331331
) : apiRequestFailedMessage ? (
332332
getIconSpan("error", errorColor)
333333
) : (
334-
<ProgressIndicator />
334+
<ProgressIndicator useSpinner={isStreaming} />
335335
),
336336
apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
337337
apiReqCancelReason === "user_cancelled" ? (
Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
11
import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
2+
import { ThinkingSpinner } from "./ThinkingSpinner"
23

3-
export const ProgressIndicator = () => (
4-
<div
5-
style={{
6-
width: "16px",
7-
height: "16px",
8-
display: "flex",
9-
alignItems: "center",
10-
justifyContent: "center",
11-
}}>
12-
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
13-
<VSCodeProgressRing />
4+
interface ProgressIndicatorProps {
5+
useSpinner?: boolean
6+
}
7+
8+
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ useSpinner = false }) => {
9+
if (useSpinner) {
10+
return (
11+
<div style={{ display: "inline-flex", alignItems: "center", gap: "8px" }}>
12+
<ThinkingSpinner className="text-vscode-foreground" />
13+
</div>
14+
)
15+
}
16+
17+
return (
18+
<div
19+
style={{
20+
width: "16px",
21+
height: "16px",
22+
display: "flex",
23+
alignItems: "center",
24+
justifyContent: "center",
25+
}}>
26+
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
27+
<VSCodeProgressRing />
28+
</div>
1429
</div>
15-
</div>
16-
)
30+
)
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* ThinkingSpinner - Animated spinner for the thinking state
3+
* Shows an animated loading spinner with "Thinking..." text
4+
*/
5+
6+
import React, { useEffect, useState } from "react"
7+
8+
interface ThinkingSpinnerProps {
9+
className?: string
10+
}
11+
12+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
13+
const ANIMATION_INTERVAL = 80 // ms per frame
14+
15+
/**
16+
* Displays an animated spinner with "Thinking..." text
17+
* Uses Braille Unicode characters for smooth animation
18+
*/
19+
export const ThinkingSpinner: React.FC<ThinkingSpinnerProps> = ({ className = "" }) => {
20+
const [frameIndex, setFrameIndex] = useState(0)
21+
22+
useEffect(() => {
23+
const interval = setInterval(() => {
24+
setFrameIndex((prev) => (prev + 1) % SPINNER_FRAMES.length)
25+
}, ANIMATION_INTERVAL)
26+
27+
return () => clearInterval(interval)
28+
}, [])
29+
30+
return <span className={className}>{SPINNER_FRAMES[frameIndex]} Thinking...</span>
31+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
2+
import { render, screen } from "@testing-library/react"
3+
import { ThinkingSpinner } from "../ThinkingSpinner"
4+
5+
describe("ThinkingSpinner", () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers()
8+
})
9+
10+
afterEach(() => {
11+
vi.runOnlyPendingTimers()
12+
vi.useRealTimers()
13+
})
14+
15+
it("renders with initial frame", () => {
16+
render(<ThinkingSpinner />)
17+
const element = screen.getByText(/Thinking/i)
18+
expect(element).toBeInTheDocument()
19+
expect(element.textContent).toContain("⠋ Thinking...")
20+
})
21+
22+
it("animates through frames", () => {
23+
render(<ThinkingSpinner />)
24+
const element = screen.getByText(/Thinking/i)
25+
26+
// Initial frame should be ⠋
27+
expect(element.textContent).toBe("⠋ Thinking...")
28+
29+
// Advance by one animation interval (80ms)
30+
vi.advanceTimersByTime(80)
31+
expect(element.textContent).toBe("⠙ Thinking...")
32+
33+
// Advance by another interval
34+
vi.advanceTimersByTime(80)
35+
expect(element.textContent).toBe("⠹ Thinking...")
36+
})
37+
38+
it("cycles through all frames", () => {
39+
render(<ThinkingSpinner />)
40+
const element = screen.getByText(/Thinking/i)
41+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
42+
43+
// Advance through all frames
44+
for (let i = 0; i < SPINNER_FRAMES.length; i++) {
45+
expect(element.textContent).toContain(SPINNER_FRAMES[i])
46+
if (i < SPINNER_FRAMES.length - 1) {
47+
vi.advanceTimersByTime(80)
48+
}
49+
}
50+
51+
// Advance one more time to wrap around
52+
vi.advanceTimersByTime(80)
53+
expect(element.textContent).toContain(SPINNER_FRAMES[0])
54+
})
55+
56+
it("accepts className prop", () => {
57+
render(<ThinkingSpinner className="text-blue-500" />)
58+
const element = screen.getByText(/Thinking/i)
59+
expect(element).toHaveClass("text-blue-500")
60+
})
61+
62+
it("cleans up interval on unmount", () => {
63+
const clearIntervalSpy = vi.spyOn(global, "clearInterval")
64+
const { unmount } = render(<ThinkingSpinner />)
65+
66+
unmount()
67+
68+
expect(clearIntervalSpy).toHaveBeenCalled()
69+
clearIntervalSpy.mockRestore()
70+
})
71+
})

0 commit comments

Comments
 (0)