Skip to content

Commit 77a61df

Browse files
committed
fix(ChatView): follow-up suggestion mode rendering crash
1 parent 92cf4e9 commit 77a61df

7 files changed

Lines changed: 182 additions & 12 deletions

File tree

packages/types/src/followup.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ export interface SuggestionItem {
2222
mode?: string
2323
}
2424

25+
export const getSuggestionMode = (mode: unknown): string | undefined => {
26+
if (typeof mode === "string" && mode.trim().length > 0) {
27+
return mode
28+
}
29+
30+
if (mode && typeof mode === "object" && "mode_slug" in mode) {
31+
const modeSlug = (mode as { mode_slug?: unknown }).mode_slug
32+
return typeof modeSlug === "string" && modeSlug.trim().length > 0 ? modeSlug : undefined
33+
}
34+
35+
return undefined
36+
}
37+
2538
/**
2639
* Zod schema for SuggestionItem
2740
*/

src/core/tools/AskFollowupQuestionTool.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Task } from "../task/Task"
22
import { formatResponse } from "../prompts/responses"
33
import type { ToolUse } from "../../shared/tools"
4+
import { getSuggestionMode } from "@roo-code/types"
45

56
import { BaseTool, ToolCallbacks } from "./BaseTool"
67

78
interface Suggestion {
89
text: string
9-
mode?: string
10+
mode?: unknown
1011
}
1112

1213
interface AskFollowupQuestionParams {
@@ -42,7 +43,7 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
4243
// Transform follow_up suggestions to the format expected by task.ask
4344
const follow_up_json = {
4445
question,
45-
suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })),
46+
suggest: follow_up.map((s) => ({ answer: s.text, mode: getSuggestionMode(s.mode) })),
4647
}
4748

4849
task.consecutiveMistakeCount = 0

src/core/tools/__tests__/askFollowupQuestionTool.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,22 @@ describe("AskFollowupQuestionTool", () => {
137137
expect(mockTask.ask).toHaveBeenCalledWith("followup", expectedJson, false)
138138
})
139139

140+
it("should normalize malformed object mode values", async () => {
141+
const params = {
142+
question: "Switch mode?",
143+
follow_up: [{ text: "Use code mode", mode: { mode_slug: "code" } }],
144+
} as any
145+
146+
await tool.execute(params, mockTask, mockCallbacks)
147+
148+
const expectedJson = JSON.stringify({
149+
question: "Switch mode?",
150+
suggest: [{ answer: "Use code mode", mode: "code" }],
151+
})
152+
153+
expect(mockTask.ask).toHaveBeenCalledWith("followup", expectedJson, false)
154+
})
155+
140156
it("should say user_feedback and push tool result after user answers", async () => {
141157
const params = {
142158
question: "Which approach?",

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,10 @@ import { appendImages } from "@src/utils/imageUtils"
1010
import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting"
1111
import { batchConsecutive } from "@src/utils/batchConsecutive"
1212

13-
import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types"
14-
import { isRetiredProvider } from "@roo-code/types"
13+
import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types"
14+
import { getSuggestionMode, isRetiredProvider } from "@roo-code/types"
1515

1616
import { findLast } from "@roo/array"
17-
import { SuggestionItem } from "@roo-code/types"
1817
import { combineApiRequests } from "@roo/combineApiRequests"
1918
import { combineCommandSequences } from "@roo/combineCommandSequences"
2019
import { getApiMetrics } from "@roo/getApiMetrics"
@@ -1336,13 +1335,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13361335

13371336
const switchToMode = useCallback(
13381337
(modeSlug: string): void => {
1338+
if (!getAllModes(customModes).some((modeConfig) => modeConfig.slug === modeSlug)) {
1339+
return
1340+
}
1341+
13391342
// Update local state and notify extension to sync mode change.
13401343
setMode(modeSlug)
13411344

13421345
// Send the mode switch message.
13431346
vscode.postMessage({ type: "mode", text: modeSlug })
13441347
},
1345-
[setMode],
1348+
[customModes, setMode],
13461349
)
13471350

13481351
const handleSuggestionClickInRow = useCallback(
@@ -1358,12 +1361,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13581361
}
13591362

13601363
// Check if we need to switch modes
1361-
if (suggestion.mode) {
1364+
const suggestionMode = getSuggestionMode(suggestion.mode)
1365+
if (suggestionMode) {
13621366
// Only switch modes if it's a manual click (event exists) or auto-approval is allowed
13631367
const isManualClick = !!event
13641368
if (isManualClick || alwaysAllowModeSwitch) {
13651369
// Switch mode without waiting
1366-
switchToMode(suggestion.mode)
1370+
switchToMode(suggestionMode)
13671371
}
13681372
}
13691373

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Button, StandardTooltip } from "@/components/ui"
55

66
import { useAppTranslation } from "@src/i18n/TranslationContext"
77
import { useExtensionState } from "@src/context/ExtensionStateContext"
8-
import { SuggestionItem } from "@roo-code/types"
8+
import { getSuggestionMode, type SuggestionItem } from "@roo-code/types"
99
import { cn } from "@/lib/utils"
1010

1111
const DEFAULT_FOLLOWUP_TIMEOUT_MS = 60000
@@ -111,6 +111,7 @@ export const FollowUpSuggest = ({
111111
<div className="flex mb-2 flex-col h-full gap-2">
112112
{suggestions.map((suggestion, index) => {
113113
const isFirstSuggestion = index === 0
114+
const suggestionMode = getSuggestionMode(suggestion.mode)
114115

115116
return (
116117
<div key={`${suggestion.answer}-${ts}`} className="w-full relative group">
@@ -134,10 +135,10 @@ export const FollowUpSuggest = ({
134135
{t("chat:followUpSuggest.timerPrefix", { seconds: countdown })}
135136
</p>
136137
)}
137-
{suggestion.mode && (
138+
{suggestionMode && (
138139
<div className="absolute bottom-0 right-0 text-[10px] text-vscode-badge-foreground pl-1 pr-2.5 pt-0.5 pb-1.5 flex items-center gap-0.5 bg-transparent rounded-xl">
139140
<span className="codicon codicon-arrow-right" style={{ fontSize: "8px" }} />
140-
{suggestion.mode}
141+
{suggestionMode}
141142
</div>
142143
)}
143144
<StandardTooltip content={t("chat:followUpSuggest.copyToInput")}>

webview-ui/src/components/chat/__tests__/ChatView.spec.tsx

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
66

77
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
88
import { vscode } from "@src/utils/vscode"
9+
import type { SuggestionItem } from "@roo-code/types"
910

1011
import ChatView, { ChatViewProps } from "../ChatView"
1112

@@ -46,7 +47,33 @@ vi.mock("use-sound", () => ({
4647

4748
// Mock components that use ESM dependencies
4849
vi.mock("../ChatRow", () => ({
49-
default: function MockChatRow({ message }: { message: ClineMessage }) {
50+
default: function MockChatRow({
51+
message,
52+
onSuggestionClick,
53+
}: {
54+
message: ClineMessage
55+
onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void
56+
}) {
57+
if (message.type === "ask" && message.ask === "followup" && message.text) {
58+
try {
59+
const followUp = JSON.parse(message.text) as { suggest?: SuggestionItem[] }
60+
return (
61+
<div data-testid="chat-row">
62+
{followUp.suggest?.map((suggestion) => (
63+
<button
64+
key={suggestion.answer}
65+
type="button"
66+
onClick={(event) => onSuggestionClick?.(suggestion, event)}>
67+
{suggestion.answer}
68+
</button>
69+
))}
70+
</div>
71+
)
72+
} catch {
73+
// Fall through to the generic row renderer.
74+
}
75+
}
76+
5077
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
5178
},
5279
}))
@@ -1050,6 +1077,97 @@ describe("ChatView - Message Queueing Tests", () => {
10501077
})
10511078
})
10521079

1080+
describe("ChatView - Follow-up Suggestions", () => {
1081+
beforeEach(() => {
1082+
vi.clearAllMocks()
1083+
vi.mocked(vscode.postMessage).mockClear()
1084+
})
1085+
1086+
it("switches to a known mode from a malformed object mode suggestion", async () => {
1087+
const { getByRole } = renderChatView()
1088+
1089+
mockPostMessage({
1090+
mode: "ask",
1091+
customModes: [],
1092+
clineMessages: [
1093+
{
1094+
type: "say",
1095+
say: "task",
1096+
ts: Date.now() - 1000,
1097+
text: "Initial task",
1098+
},
1099+
{
1100+
type: "ask",
1101+
ask: "followup",
1102+
ts: Date.now(),
1103+
text: JSON.stringify({
1104+
question: "Switch mode?",
1105+
suggest: [{ answer: "Use code mode", mode: { mode_slug: "code" } }],
1106+
}),
1107+
partial: false,
1108+
},
1109+
],
1110+
})
1111+
1112+
const suggestion = await waitFor(() => getByRole("button", { name: "Use code mode" }))
1113+
vi.mocked(vscode.postMessage).mockClear()
1114+
1115+
fireEvent.click(suggestion)
1116+
1117+
await waitFor(() => {
1118+
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "mode", text: "code" })
1119+
})
1120+
expect(vscode.postMessage).toHaveBeenCalledWith({
1121+
type: "askResponse",
1122+
askResponse: "messageResponse",
1123+
text: "Use code mode",
1124+
images: [],
1125+
})
1126+
})
1127+
1128+
it("does not switch modes for an unknown malformed object mode suggestion", async () => {
1129+
const { getByRole } = renderChatView()
1130+
1131+
mockPostMessage({
1132+
mode: "ask",
1133+
customModes: [],
1134+
clineMessages: [
1135+
{
1136+
type: "say",
1137+
say: "task",
1138+
ts: Date.now() - 1000,
1139+
text: "Initial task",
1140+
},
1141+
{
1142+
type: "ask",
1143+
ask: "followup",
1144+
ts: Date.now(),
1145+
text: JSON.stringify({
1146+
question: "Switch mode?",
1147+
suggest: [{ answer: "Use invalid mode", mode: { mode_slug: "not-a-mode" } }],
1148+
}),
1149+
partial: false,
1150+
},
1151+
],
1152+
})
1153+
1154+
const suggestion = await waitFor(() => getByRole("button", { name: "Use invalid mode" }))
1155+
vi.mocked(vscode.postMessage).mockClear()
1156+
1157+
fireEvent.click(suggestion)
1158+
1159+
await waitFor(() => {
1160+
expect(vscode.postMessage).toHaveBeenCalledWith({
1161+
type: "askResponse",
1162+
askResponse: "messageResponse",
1163+
text: "Use invalid mode",
1164+
images: [],
1165+
})
1166+
})
1167+
expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "mode" }))
1168+
})
1169+
})
1170+
10531171
describe("ChatView - Context Condensing Indicator Tests", () => {
10541172
beforeEach(() => {
10551173
vi.clearAllMocks()

webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,23 @@ describe("FollowUpSuggest", () => {
243243
expect(container.firstChild).toBeNull()
244244
})
245245

246+
it("should render malformed object mode values without crashing", () => {
247+
const suggestions = [{ answer: "Use code mode", mode: { mode_slug: "code" } }] as any
248+
249+
renderWithTestProviders(
250+
<FollowUpSuggest
251+
suggestions={suggestions}
252+
onSuggestionClick={mockOnSuggestionClick}
253+
ts={123}
254+
onCancelAutoApproval={mockOnCancelAutoApproval}
255+
/>,
256+
defaultTestState,
257+
)
258+
259+
expect(screen.getByText("Use code mode")).toBeInTheDocument()
260+
expect(screen.getByText("code")).toBeInTheDocument()
261+
})
262+
246263
it("should stop countdown when user manually responds (isAnswered becomes true)", () => {
247264
const { rerender } = renderWithTestProviders(
248265
<FollowUpSuggest

0 commit comments

Comments
 (0)