Skip to content

Commit 294d34a

Browse files
committed
fix: handle null/undefined token values in ContextCondenseRow to prevent UI crash
- Added null-safe operators for prevContextTokens, newContextTokens, and cost - Fallback to "0" for null/undefined token values - Added comprehensive test coverage for edge cases - Fixes #6914
1 parent f53fd39 commit 294d34a

File tree

2 files changed

+180
-2
lines changed

2 files changed

+180
-2
lines changed

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens,
3333
<span className="codicon codicon-compress text-blue-400" />
3434
<span className="font-bold text-vscode-foreground">{t("chat:contextCondense.title")}</span>
3535
<span className="text-vscode-descriptionForeground text-sm">
36-
{prevContextTokens.toLocaleString()}{newContextTokens.toLocaleString()} {t("tokens")}
36+
{prevContextTokens?.toLocaleString() ?? "0"}{newContextTokens?.toLocaleString() ?? "0"}{" "}
37+
{t("tokens")}
3738
</span>
38-
<VSCodeBadge className={cost > 0 ? "opacity-100" : "opacity-0"}>${cost.toFixed(2)}</VSCodeBadge>
39+
<VSCodeBadge className={cost > 0 ? "opacity-100" : "opacity-0"}>
40+
${(cost ?? 0).toFixed(2)}
41+
</VSCodeBadge>
3942
</div>
4043
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
4144
</div>
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// npx vitest run src/components/chat/__tests__/ContextCondenseRow.spec.tsx
2+
3+
import React from "react"
4+
import { render, fireEvent } from "@/utils/test-utils"
5+
import { ContextCondenseRow, CondensingContextRow, CondenseContextErrorRow } from "../ContextCondenseRow"
6+
7+
// Mock i18n
8+
vi.mock("react-i18next", () => ({
9+
useTranslation: () => ({
10+
t: (key: string) => key,
11+
}),
12+
}))
13+
14+
// Mock VSCode components
15+
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
16+
VSCodeBadge: function MockVSCodeBadge({ children, className }: { children: React.ReactNode; className?: string }) {
17+
return <span className={className}>{children}</span>
18+
},
19+
}))
20+
21+
// Mock Markdown component
22+
vi.mock("../Markdown", () => ({
23+
Markdown: function MockMarkdown({ markdown }: { markdown: string }) {
24+
return <div data-testid="markdown">{markdown}</div>
25+
},
26+
}))
27+
28+
// Mock ProgressIndicator component
29+
vi.mock("../ProgressIndicator", () => ({
30+
ProgressIndicator: function MockProgressIndicator() {
31+
return <div data-testid="progress-indicator">Loading...</div>
32+
},
33+
}))
34+
35+
describe("ContextCondenseRow", () => {
36+
it("renders with valid token values", () => {
37+
const { getByText } = render(
38+
<ContextCondenseRow
39+
cost={1.5}
40+
prevContextTokens={1000}
41+
newContextTokens={500}
42+
summary="Context condensed successfully"
43+
/>,
44+
)
45+
46+
expect(getByText("chat:contextCondense.title")).toBeInTheDocument()
47+
expect(getByText(/1,000 500 tokens/)).toBeInTheDocument()
48+
expect(getByText("$1.50")).toBeInTheDocument()
49+
})
50+
51+
it("handles null token values gracefully", () => {
52+
const { getByText } = render(
53+
<ContextCondenseRow
54+
cost={0}
55+
prevContextTokens={null as any}
56+
newContextTokens={null as any}
57+
summary="Context condensed"
58+
/>,
59+
)
60+
61+
expect(getByText("chat:contextCondense.title")).toBeInTheDocument()
62+
// Should display "0" for null values
63+
expect(getByText(/0 0 tokens/)).toBeInTheDocument()
64+
})
65+
66+
it("handles undefined token values gracefully", () => {
67+
const { getByText } = render(
68+
<ContextCondenseRow
69+
cost={undefined as any}
70+
prevContextTokens={undefined as any}
71+
newContextTokens={undefined as any}
72+
summary="Context condensed"
73+
/>,
74+
)
75+
76+
expect(getByText("chat:contextCondense.title")).toBeInTheDocument()
77+
// Should display "0" for undefined values
78+
expect(getByText(/0 0 tokens/)).toBeInTheDocument()
79+
expect(getByText("$0.00")).toBeInTheDocument()
80+
})
81+
82+
it("handles mixed null and valid token values", () => {
83+
const { getByText } = render(
84+
<ContextCondenseRow
85+
cost={2.5}
86+
prevContextTokens={2000}
87+
newContextTokens={null as any}
88+
summary="Context condensed"
89+
/>,
90+
)
91+
92+
expect(getByText("chat:contextCondense.title")).toBeInTheDocument()
93+
// Should display "2,000 → 0 tokens" for mixed values
94+
expect(getByText(/2,000 0 tokens/)).toBeInTheDocument()
95+
expect(getByText("$2.50")).toBeInTheDocument()
96+
})
97+
98+
it("expands and collapses when clicked", () => {
99+
const { getByText, queryByTestId } = render(
100+
<ContextCondenseRow
101+
cost={1.5}
102+
prevContextTokens={1000}
103+
newContextTokens={500}
104+
summary="Context condensed successfully"
105+
/>,
106+
)
107+
108+
// Initially collapsed
109+
expect(queryByTestId("markdown")).not.toBeInTheDocument()
110+
111+
// Click to expand
112+
const header = getByText("chat:contextCondense.title").parentElement?.parentElement
113+
fireEvent.click(header!)
114+
115+
// Should show summary
116+
expect(queryByTestId("markdown")).toBeInTheDocument()
117+
expect(getByText("Context condensed successfully")).toBeInTheDocument()
118+
119+
// Click to collapse
120+
fireEvent.click(header!)
121+
122+
// Should hide summary
123+
expect(queryByTestId("markdown")).not.toBeInTheDocument()
124+
})
125+
126+
it("hides badge when cost is 0", () => {
127+
const { container } = render(
128+
<ContextCondenseRow cost={0} prevContextTokens={1000} newContextTokens={500} summary="Context condensed" />,
129+
)
130+
131+
const badge = container.querySelector(".opacity-0")
132+
expect(badge).toBeInTheDocument()
133+
})
134+
135+
it("shows badge when cost is greater than 0", () => {
136+
const { container } = render(
137+
<ContextCondenseRow
138+
cost={1.5}
139+
prevContextTokens={1000}
140+
newContextTokens={500}
141+
summary="Context condensed"
142+
/>,
143+
)
144+
145+
const badge = container.querySelector(".opacity-100")
146+
expect(badge).toBeInTheDocument()
147+
})
148+
})
149+
150+
describe("CondensingContextRow", () => {
151+
it("renders with progress indicator", () => {
152+
const { getByText, getByTestId } = render(<CondensingContextRow />)
153+
154+
expect(getByTestId("progress-indicator")).toBeInTheDocument()
155+
expect(getByText("chat:contextCondense.condensing")).toBeInTheDocument()
156+
})
157+
})
158+
159+
describe("CondenseContextErrorRow", () => {
160+
it("renders with error text", () => {
161+
const errorText = "Failed to condense context: API error"
162+
const { getByText } = render(<CondenseContextErrorRow errorText={errorText} />)
163+
164+
expect(getByText("chat:contextCondense.errorHeader")).toBeInTheDocument()
165+
expect(getByText(errorText)).toBeInTheDocument()
166+
})
167+
168+
it("renders without error text", () => {
169+
const { getByText, queryByText } = render(<CondenseContextErrorRow />)
170+
171+
expect(getByText("chat:contextCondense.errorHeader")).toBeInTheDocument()
172+
// Should not show any error text when not provided
173+
expect(queryByText(/Failed/)).not.toBeInTheDocument()
174+
})
175+
})

0 commit comments

Comments
 (0)