mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
feat: implement multi-question support with options and UI enhancements
This commit is contained in:
parent
08aa544b6a
commit
5521e0df82
15 changed files with 420 additions and 114 deletions
|
|
@ -197,6 +197,12 @@ export const globalSettingsSchema = z.object({
|
|||
hasOpenedModeSelector: z.boolean().optional(),
|
||||
lastModeExportPath: z.string().optional(),
|
||||
lastModeImportPath: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Whether to show multiple questions one by one or all at once.
|
||||
* @default false (all at once)
|
||||
*/
|
||||
showQuestionsOneByOne: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type GlobalSettings = z.infer<typeof globalSettingsSchema>
|
||||
|
|
@ -364,6 +370,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
|
|||
mode: "code", // "architect",
|
||||
|
||||
customModes: [],
|
||||
showQuestionsOneByOne: false,
|
||||
}
|
||||
|
||||
export const EVALS_TIMEOUT = 5 * 60 * 1_000
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export type ExtensionState = Pick<
|
|||
| "enterBehavior"
|
||||
| "includeCurrentTime"
|
||||
| "includeCurrentCost"
|
||||
| "showQuestionsOneByOne"
|
||||
| "maxGitStatusFiles"
|
||||
| "requestDelaySeconds"
|
||||
> & {
|
||||
|
|
|
|||
|
|
@ -394,9 +394,9 @@ export class NativeToolCallParser {
|
|||
break
|
||||
|
||||
case "ask_followup_question":
|
||||
if (partialArgs.question !== undefined || partialArgs.follow_up !== undefined) {
|
||||
if (partialArgs.questions !== undefined || partialArgs.follow_up !== undefined) {
|
||||
nativeArgs = {
|
||||
question: partialArgs.question,
|
||||
questions: Array.isArray(partialArgs.questions) ? partialArgs.questions : undefined,
|
||||
follow_up: Array.isArray(partialArgs.follow_up) ? partialArgs.follow_up : undefined,
|
||||
}
|
||||
}
|
||||
|
|
@ -676,9 +676,9 @@ export class NativeToolCallParser {
|
|||
break
|
||||
|
||||
case "ask_followup_question":
|
||||
if (args.question !== undefined && args.follow_up !== undefined) {
|
||||
if (args.questions !== undefined && args.follow_up !== undefined) {
|
||||
nativeArgs = {
|
||||
question: args.question,
|
||||
questions: Array.isArray(args.questions) ? args.questions : undefined,
|
||||
follow_up: args.follow_up,
|
||||
} as NativeArgsFor<TName>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,26 @@ import type OpenAI from "openai"
|
|||
const ASK_FOLLOWUP_QUESTION_DESCRIPTION = `Ask the user a question to gather additional information needed to complete the task. Use when you need clarification or more details to proceed effectively.
|
||||
|
||||
Parameters:
|
||||
- question: (required) A clear, specific question addressing the information needed
|
||||
- questions: (required) A list of questions to ask. Each question can be a simple string or an object with "text" and "options" for multiple choice.
|
||||
- follow_up: (required) A list of 2-4 suggested answers. Suggestions must be complete, actionable answers without placeholders. Optionally include mode to switch modes (code/architect/etc.)
|
||||
|
||||
Example: Asking for file path
|
||||
{ "question": "What is the path to the frontend-config.json file?", "follow_up": [{ "text": "./src/frontend-config.json", "mode": null }, { "text": "./config/frontend-config.json", "mode": null }, { "text": "./frontend-config.json", "mode": null }] }
|
||||
{ "questions": ["What is the path to the frontend-config.json file?"], "follow_up": [{ "text": "./src/frontend-config.json", "mode": null }, { "text": "./config/frontend-config.json", "mode": null }, { "text": "./frontend-config.json", "mode": null }] }
|
||||
|
||||
Example: Asking with multiple questions and choices
|
||||
{
|
||||
"questions": [
|
||||
{ "text": "Which framework are you using?", "options": ["React", "Vue", "Svelte", "Other"] },
|
||||
"What is your project name?",
|
||||
{ "text": "Include telemetry?", "options": ["Yes", "No"] }
|
||||
],
|
||||
"follow_up": [{ "text": "I've answered the questions", "mode": null }]
|
||||
}
|
||||
|
||||
Example: Asking with mode switch
|
||||
{ "question": "Would you like me to implement this feature?", "follow_up": [{ "text": "Yes, implement it now", "mode": "code" }, { "text": "No, just plan it out", "mode": "architect" }] }`
|
||||
{ "questions": ["Would you like me to implement this feature?"], "follow_up": [{ "text": "Yes, implement it now", "mode": "code" }, { "text": "No, just plan it out", "mode": "architect" }] }`
|
||||
|
||||
const QUESTION_PARAMETER_DESCRIPTION = `Clear, specific question that captures the missing information you need`
|
||||
const QUESTIONS_PARAMETER_DESCRIPTION = `List of questions to ask. Each question can be a string or an object with "text" and "options" for multiple choice.`
|
||||
|
||||
const FOLLOW_UP_PARAMETER_DESCRIPTION = `Required list of 2-4 suggested responses; each suggestion must be a complete, actionable answer and may include a mode switch`
|
||||
|
||||
|
|
@ -29,9 +39,29 @@ export default {
|
|||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
question: {
|
||||
type: "string",
|
||||
description: QUESTION_PARAMETER_DESCRIPTION,
|
||||
questions: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
options: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["text", "options"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
description: QUESTIONS_PARAMETER_DESCRIPTION,
|
||||
minItems: 1,
|
||||
},
|
||||
follow_up: {
|
||||
type: "array",
|
||||
|
|
@ -55,7 +85,7 @@ export default {
|
|||
maxItems: 4,
|
||||
},
|
||||
},
|
||||
required: ["question", "follow_up"],
|
||||
required: ["questions", "follow_up"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ interface Suggestion {
|
|||
mode?: string
|
||||
}
|
||||
|
||||
interface Question {
|
||||
text: string
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
interface AskFollowupQuestionParams {
|
||||
question: string
|
||||
questions?: string[]
|
||||
questions: Array<string | Question>
|
||||
follow_up: Suggestion[]
|
||||
}
|
||||
|
||||
|
|
@ -25,21 +29,33 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
|
|||
const follow_up_xml = params.follow_up
|
||||
|
||||
const suggestions: Suggestion[] = []
|
||||
const questions: string[] = []
|
||||
const questions: Array<string | Question> = []
|
||||
|
||||
if (questions_xml) {
|
||||
try {
|
||||
// Handle both simple <question> tags and more complex <question> tags with options
|
||||
const parsedQuestions = parseXml(questions_xml, ["question"]) as {
|
||||
question: string[] | string
|
||||
question: any[] | any
|
||||
}
|
||||
|
||||
const rawQuestions = Array.isArray(parsedQuestions?.question)
|
||||
? parsedQuestions.question
|
||||
: [parsedQuestions?.question].filter((q): q is string => q !== undefined)
|
||||
: [parsedQuestions?.question].filter((q): q is any => q !== undefined)
|
||||
|
||||
for (const q of rawQuestions) {
|
||||
if (typeof q === "string") {
|
||||
questions.push(q)
|
||||
} else if (typeof q === "object" && q !== null) {
|
||||
const text = q["#text"] || ""
|
||||
const optionsStr = q["@_options"]
|
||||
if (optionsStr) {
|
||||
questions.push({
|
||||
text,
|
||||
options: optionsStr.split(",").map((o: string) => o.trim()),
|
||||
})
|
||||
} else {
|
||||
questions.push(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -49,8 +65,12 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
|
|||
}
|
||||
}
|
||||
|
||||
// If no questions array but we have a single question, use that
|
||||
if (questions.length === 0 && question) {
|
||||
questions.push(question)
|
||||
}
|
||||
|
||||
if (follow_up_xml) {
|
||||
// Define the actual structure returned by the XML parser
|
||||
type ParsedSuggestion = string | { "#text": string; "@_mode"?: string }
|
||||
|
||||
try {
|
||||
|
|
@ -62,13 +82,10 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
|
|||
? parsedSuggest.suggest
|
||||
: [parsedSuggest?.suggest].filter((sug): sug is ParsedSuggestion => sug !== undefined)
|
||||
|
||||
// Transform parsed XML to our Suggest format
|
||||
for (const sug of rawSuggestions) {
|
||||
if (typeof sug === "string") {
|
||||
// Simple string suggestion (no mode attribute)
|
||||
suggestions.push({ text: sug })
|
||||
} else {
|
||||
// XML object with text content and optional mode attribute
|
||||
const suggestion: Suggestion = { text: sug["#text"] }
|
||||
if (sug["@_mode"]) {
|
||||
suggestion.mode = sug["@_mode"]
|
||||
|
|
@ -84,34 +101,32 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
|
|||
}
|
||||
|
||||
return {
|
||||
question,
|
||||
questions,
|
||||
follow_up: suggestions,
|
||||
}
|
||||
}
|
||||
|
||||
async execute(params: AskFollowupQuestionParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
const { question, questions, follow_up } = params
|
||||
const { handleError, pushToolResult, toolProtocol } = callbacks
|
||||
const { questions, follow_up } = params
|
||||
const { handleError, pushToolResult } = callbacks
|
||||
|
||||
try {
|
||||
if (!question && (!questions || questions.length === 0)) {
|
||||
if (!questions || questions.length === 0) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("ask_followup_question")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(await task.sayAndCreateMissingParamError("ask_followup_question", "question"))
|
||||
pushToolResult(await task.sayAndCreateMissingParamError("ask_followup_question", "questions"))
|
||||
return
|
||||
}
|
||||
|
||||
// Transform follow_up suggestions to the format expected by task.ask
|
||||
const follow_up_json = {
|
||||
question,
|
||||
const followup_json = {
|
||||
questions,
|
||||
suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })),
|
||||
}
|
||||
|
||||
task.consecutiveMistakeCount = 0
|
||||
const { text, images } = await task.ask("followup", JSON.stringify(follow_up_json), false)
|
||||
const { text, images } = await task.ask("followup", JSON.stringify(followup_json), false)
|
||||
await task.say("user_feedback", text ?? "", images)
|
||||
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
|
||||
} catch (error) {
|
||||
|
|
@ -120,15 +135,15 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
|
|||
}
|
||||
|
||||
override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise<void> {
|
||||
// Get question from params (for XML protocol) or nativeArgs (for native protocol)
|
||||
const question: string | undefined = block.params.question ?? block.nativeArgs?.question
|
||||
// For now we don't stream multiple questions, only the main one if present
|
||||
// We could improve this to stream multiple questions but it requires UI changes to handle partial arrays
|
||||
// Get first question from questions array for streaming display
|
||||
const questions = block.nativeArgs?.questions ?? []
|
||||
const firstQuestion = questions[0]
|
||||
const questionText = typeof firstQuestion === "string" ? firstQuestion : firstQuestion?.text
|
||||
|
||||
// During partial streaming, only show the question to avoid displaying raw JSON
|
||||
// The full JSON with suggestions will be sent when the tool call is complete (!block.partial)
|
||||
// During partial streaming, only show the first question to avoid displaying raw JSON
|
||||
// The full JSON with all questions and suggestions will be sent when the tool call is complete
|
||||
await task
|
||||
.ask("followup", this.removeClosingTag("question", question, block.partial), block.partial)
|
||||
.ask("followup", this.removeClosingTag("question", questionText, block.partial), block.partial)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,18 +129,79 @@ describe("askFollowupQuestionTool", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("should handle multiple questions in native protocol", async () => {
|
||||
const block: ToolUse<"ask_followup_question"> = {
|
||||
type: "tool_use",
|
||||
name: "ask_followup_question",
|
||||
params: {},
|
||||
nativeArgs: {
|
||||
questions: ["Question A", "Question B"],
|
||||
follow_up: [{ text: "Okay", mode: "code" }],
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
await askFollowupQuestionTool.handle(mockCline, block, {
|
||||
askApproval: vi.fn(),
|
||||
handleError: vi.fn(),
|
||||
pushToolResult: mockPushToolResult,
|
||||
removeClosingTag: vi.fn((tag, content) => content),
|
||||
toolProtocol: "native",
|
||||
})
|
||||
|
||||
expect(mockCline.ask).toHaveBeenCalledWith(
|
||||
"followup",
|
||||
expect.stringContaining('"questions":["Question A","Question B"]'),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multiple-choice questions in native protocol", async () => {
|
||||
const block: ToolUse<"ask_followup_question"> = {
|
||||
type: "tool_use",
|
||||
name: "ask_followup_question",
|
||||
params: {},
|
||||
nativeArgs: {
|
||||
questions: [
|
||||
{ text: "Framework?", options: ["React", "Vue"] },
|
||||
"Project name?",
|
||||
{ text: "Deploy?", options: ["Yes", "No"] },
|
||||
],
|
||||
follow_up: [{ text: "Done", mode: "code" }],
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
await askFollowupQuestionTool.handle(mockCline, block, {
|
||||
askApproval: vi.fn(),
|
||||
handleError: vi.fn(),
|
||||
pushToolResult: mockPushToolResult,
|
||||
removeClosingTag: vi.fn((tag, content) => content),
|
||||
toolProtocol: "native",
|
||||
})
|
||||
|
||||
expect(mockCline.ask).toHaveBeenCalledWith(
|
||||
"followup",
|
||||
expect.stringContaining(
|
||||
'"questions":[{"text":"Framework?","options":["React","Vue"]},"Project name?",{"text":"Deploy?","options":["Yes","No"]}]',
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
describe("handlePartial with native protocol", () => {
|
||||
it("should only send question during partial streaming to avoid raw JSON display", async () => {
|
||||
it("should only send first question during partial streaming to avoid raw JSON display", async () => {
|
||||
const block: ToolUse<"ask_followup_question"> = {
|
||||
type: "tool_use",
|
||||
name: "ask_followup_question",
|
||||
params: {
|
||||
question: "What would you like to do?",
|
||||
},
|
||||
params: {},
|
||||
partial: true,
|
||||
nativeArgs: {
|
||||
question: "What would you like to do?",
|
||||
follow_up: [{ text: "Option 1", mode: "code" }, { text: "Option 2" }],
|
||||
questions: ["What would you like to do?"],
|
||||
follow_up: [
|
||||
{ text: "Option 1", mode: "code" },
|
||||
{ text: "Option 2", mode: "architect" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -152,18 +213,20 @@ describe("askFollowupQuestionTool", () => {
|
|||
toolProtocol: "native",
|
||||
})
|
||||
|
||||
// During partial streaming, only the question should be sent (not JSON with suggestions)
|
||||
// During partial streaming, only the first question should be sent (not JSON with suggestions)
|
||||
expect(mockCline.ask).toHaveBeenCalledWith("followup", "What would you like to do?", true)
|
||||
})
|
||||
|
||||
it("should handle partial with question from params", async () => {
|
||||
it("should handle partial with multiple questions", async () => {
|
||||
const block: ToolUse<"ask_followup_question"> = {
|
||||
type: "tool_use",
|
||||
name: "ask_followup_question",
|
||||
params: {
|
||||
question: "Choose wisely",
|
||||
},
|
||||
params: {},
|
||||
partial: true,
|
||||
nativeArgs: {
|
||||
questions: ["Question 1", "Question 2"],
|
||||
follow_up: [],
|
||||
},
|
||||
}
|
||||
|
||||
await askFollowupQuestionTool.handle(mockCline, block, {
|
||||
|
|
@ -171,10 +234,11 @@ describe("askFollowupQuestionTool", () => {
|
|||
handleError: vi.fn(),
|
||||
pushToolResult: mockPushToolResult,
|
||||
removeClosingTag: vi.fn((tag, content) => content || ""),
|
||||
toolProtocol: "xml",
|
||||
toolProtocol: "native",
|
||||
})
|
||||
|
||||
expect(mockCline.ask).toHaveBeenCalledWith("followup", "Choose wisely", true)
|
||||
// Should show first question during streaming
|
||||
expect(mockCline.ask).toHaveBeenCalledWith("followup", "Question 1", true)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -184,34 +248,33 @@ describe("askFollowupQuestionTool", () => {
|
|||
NativeToolCallParser.clearRawChunkState()
|
||||
})
|
||||
|
||||
it("should build nativeArgs with question and follow_up during streaming", () => {
|
||||
it("should build nativeArgs with questions and follow_up during streaming", () => {
|
||||
// Start a streaming tool call
|
||||
NativeToolCallParser.startStreamingToolCall("call_123", "ask_followup_question")
|
||||
|
||||
// Simulate streaming JSON chunks
|
||||
const chunk1 = '{"question":"What would you like?","follow_up":[{"text":"Option 1","mode":"code"}'
|
||||
const chunk1 = '{"questions":["What would you like?"],"follow_up":[{"text":"Option 1","mode":"code"}'
|
||||
const result1 = NativeToolCallParser.processStreamingChunk("call_123", chunk1)
|
||||
|
||||
expect(result1).not.toBeNull()
|
||||
expect(result1?.name).toBe("ask_followup_question")
|
||||
expect(result1?.params.question).toBe("What would you like?")
|
||||
expect(result1?.nativeArgs).toBeDefined()
|
||||
// Use type assertion to access the specific fields
|
||||
const nativeArgs = result1?.nativeArgs as {
|
||||
question: string
|
||||
questions: string[]
|
||||
follow_up?: Array<{ text: string; mode?: string }>
|
||||
}
|
||||
expect(nativeArgs?.question).toBe("What would you like?")
|
||||
expect(nativeArgs?.questions).toEqual(["What would you like?"])
|
||||
// partial-json should parse the incomplete array
|
||||
expect(nativeArgs?.follow_up).toBeDefined()
|
||||
})
|
||||
|
||||
it("should finalize with complete nativeArgs", () => {
|
||||
it("should finalize with complete nativeArgs including complex questions", () => {
|
||||
NativeToolCallParser.startStreamingToolCall("call_456", "ask_followup_question")
|
||||
|
||||
// Add complete JSON
|
||||
const completeJson =
|
||||
'{"question":"Choose an option","follow_up":[{"text":"Yes","mode":"code"},{"text":"No","mode":null}]}'
|
||||
'{"questions":[{"text":"Framework?","options":["React","Vue"]},"Name?"],"follow_up":[{"text":"Yes","mode":"code"},{"text":"No","mode":"architect"}]}'
|
||||
NativeToolCallParser.processStreamingChunk("call_456", completeJson)
|
||||
|
||||
const result = NativeToolCallParser.finalizeStreamingToolCall("call_456")
|
||||
|
|
@ -223,10 +286,10 @@ describe("askFollowupQuestionTool", () => {
|
|||
// Type guard: regular tools have type 'tool_use', MCP tools have type 'mcp_tool_use'
|
||||
if (result?.type === "tool_use") {
|
||||
expect(result.nativeArgs).toEqual({
|
||||
question: "Choose an option",
|
||||
questions: [{ text: "Framework?", options: ["React", "Vue"] }, "Name?"],
|
||||
follow_up: [
|
||||
{ text: "Yes", mode: "code" },
|
||||
{ text: "No", mode: null },
|
||||
{ text: "No", mode: "architect" },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export type NativeToolArgs = {
|
|||
edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number }
|
||||
apply_patch: { patch: string }
|
||||
ask_followup_question: {
|
||||
question: string
|
||||
questions: Array<string | { text: string; options?: string[] }>
|
||||
follow_up: Array<{ text: string; mode?: string }>
|
||||
}
|
||||
browser_action: BrowserActionParams
|
||||
|
|
|
|||
|
|
@ -1,82 +1,181 @@
|
|||
import React, { useState, useEffect } from "react"
|
||||
import { Button, Textarea } from "@/components/ui"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button, AutosizeTextarea } from "@/components/ui"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
|
||||
interface Question {
|
||||
text: string
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
interface MultiQuestionHandlerProps {
|
||||
questions: string[]
|
||||
questions: Array<string | Question>
|
||||
onSendResponse: (response: string) => void
|
||||
}
|
||||
|
||||
interface QuestionItemProps {
|
||||
question: string | Question
|
||||
title: string
|
||||
textValue: string
|
||||
selectedOption?: string
|
||||
onTextChange: (value: string) => void
|
||||
onOptionClick: (option: string) => void
|
||||
}
|
||||
|
||||
const QuestionItem = ({
|
||||
question,
|
||||
title,
|
||||
textValue,
|
||||
selectedOption,
|
||||
onTextChange,
|
||||
onOptionClick,
|
||||
}: QuestionItemProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
const qText = typeof question === "string" ? question : question.text
|
||||
const options = typeof question === "string" ? undefined : question.options
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="font-bold">{title}</div>
|
||||
<div>{qText}</div>
|
||||
{options && options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onOptionClick(option)}
|
||||
className={`px-3 py-1.5 rounded text-sm transition-colors border ${
|
||||
selectedOption === option
|
||||
? "bg-vscode-button-background text-vscode-button-foreground border-vscode-button-background"
|
||||
: "bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground border-vscode-button-secondaryHoverBackground hover:bg-vscode-button-secondaryHoverBackground"
|
||||
}`}>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AutosizeTextarea
|
||||
value={textValue}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
minHeight={21}
|
||||
maxHeight={200}
|
||||
placeholder={t("chat:questions.typeAnswer")}
|
||||
className="w-full py-2 pl-3 pr-3 rounded border border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultiQuestionHandler = ({ questions, onSendResponse }: MultiQuestionHandlerProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
const { showQuestionsOneByOne } = useExtensionState()
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<string[]>(new Array(questions.length).fill(""))
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [selectedOptions, setSelectedOptions] = useState<(string | undefined)[]>(
|
||||
new Array(questions.length).fill(undefined),
|
||||
)
|
||||
const [textAnswers, setTextAnswers] = useState<string[]>(new Array(questions.length).fill(""))
|
||||
const [oneByOneInputValue, setOneByOneInputValue] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(answers[currentQuestionIndex] || "")
|
||||
}, [currentQuestionIndex, answers])
|
||||
if (showQuestionsOneByOne) {
|
||||
setOneByOneInputValue(textAnswers[currentQuestionIndex] || "")
|
||||
}
|
||||
}, [currentQuestionIndex, textAnswers, showQuestionsOneByOne])
|
||||
|
||||
const updateTextAnswer = (index: number, value: string) => {
|
||||
const next = [...textAnswers]
|
||||
next[index] = value
|
||||
setTextAnswers(next)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const newAnswers = [...answers]
|
||||
newAnswers[currentQuestionIndex] = inputValue
|
||||
setAnswers(newAnswers)
|
||||
|
||||
if (currentQuestionIndex < questions.length - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1)
|
||||
}
|
||||
updateTextAnswer(currentQuestionIndex, oneByOneInputValue)
|
||||
if (currentQuestionIndex < questions.length - 1) setCurrentQuestionIndex(currentQuestionIndex + 1)
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
const newAnswers = [...answers]
|
||||
newAnswers[currentQuestionIndex] = inputValue
|
||||
setAnswers(newAnswers)
|
||||
updateTextAnswer(currentQuestionIndex, oneByOneInputValue)
|
||||
if (currentQuestionIndex > 0) setCurrentQuestionIndex(currentQuestionIndex - 1)
|
||||
}
|
||||
|
||||
if (currentQuestionIndex > 0) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex - 1)
|
||||
}
|
||||
const handleOptionClick = (index: number, option: string) => {
|
||||
setSelectedOptions((prev) => {
|
||||
const next = [...prev]
|
||||
next[index] = next[index] === option ? undefined : option
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
const newAnswers = [...answers]
|
||||
newAnswers[currentQuestionIndex] = inputValue
|
||||
setAnswers(newAnswers)
|
||||
let finalAnswers = textAnswers
|
||||
if (showQuestionsOneByOne) {
|
||||
finalAnswers = [...textAnswers]
|
||||
finalAnswers[currentQuestionIndex] = oneByOneInputValue
|
||||
}
|
||||
|
||||
const combined = questions.map((q, i) => `Question: ${q}\nAnswer: ${newAnswers[i] || "(skipped)"}`).join("\n\n")
|
||||
const combined = questions
|
||||
.map((q, i) => {
|
||||
const qText = typeof q === "string" ? q : q.text
|
||||
const text = finalAnswers[i].trim()
|
||||
const option = selectedOptions[i]
|
||||
const answer = option && text ? `${option}: ${text}` : option || text || "(skipped)"
|
||||
return `Question: ${qText}\nAnswer: ${answer}`
|
||||
})
|
||||
.join("\n\n")
|
||||
onSendResponse(combined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="font-bold">
|
||||
{t("chat:questions.questionNumberOfTotal", {
|
||||
current: currentQuestionIndex + 1,
|
||||
total: questions.length,
|
||||
})}
|
||||
if (showQuestionsOneByOne) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<QuestionItem
|
||||
question={questions[currentQuestionIndex]}
|
||||
title={t("chat:questions.questionNumberOfTotal", {
|
||||
current: currentQuestionIndex + 1,
|
||||
total: questions.length,
|
||||
})}
|
||||
textValue={oneByOneInputValue}
|
||||
selectedOption={selectedOptions[currentQuestionIndex]}
|
||||
onTextChange={setOneByOneInputValue}
|
||||
onOptionClick={(opt) => handleOptionClick(currentQuestionIndex, opt)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{currentQuestionIndex > 0 && (
|
||||
<Button variant="secondary" onClick={handlePrevious}>
|
||||
{t("chat:questions.previous")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={currentQuestionIndex < questions.length - 1 ? handleNext : handleFinish}>
|
||||
{t(
|
||||
currentQuestionIndex < questions.length - 1
|
||||
? "chat:questions.next"
|
||||
: "chat:questions.finish",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>{questions[currentQuestionIndex]}</div>
|
||||
<Textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t("chat:questions.typeAnswer")}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{currentQuestionIndex > 0 && (
|
||||
<Button variant="secondary" onClick={handlePrevious}>
|
||||
{t("chat:questions.previous")}
|
||||
</Button>
|
||||
)}
|
||||
{currentQuestionIndex < questions.length - 1 ? (
|
||||
<Button variant="primary" onClick={handleNext}>
|
||||
{t("chat:questions.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={handleFinish}>
|
||||
{t("chat:questions.finish")}
|
||||
</Button>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{questions.map((q, i) => (
|
||||
<QuestionItem
|
||||
key={i}
|
||||
question={q}
|
||||
title={t("chat:questions.questionNumber", { number: i + 1 })}
|
||||
textValue={textAnswers[i]}
|
||||
selectedOption={selectedOptions[i]}
|
||||
onTextChange={(val) => updateTextAnswer(i, val)}
|
||||
onOptionClick={(opt) => handleOptionClick(i, opt)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleFinish}>
|
||||
{t("chat:questions.finish")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MultiQuestionHandler } from "../MultiQuestionHandler"
|
|||
vi.mock("@src/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: () => ({
|
||||
language: "en",
|
||||
showQuestionsOneByOne: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
|
@ -19,6 +20,9 @@ vi.mock("react-i18next", () => ({
|
|||
if (key === "chat:questions.questionNumberOfTotal" && options) {
|
||||
return `Question ${options.current} of ${options.total}`
|
||||
}
|
||||
if (key === "chat:questions.questionNumber" && options) {
|
||||
return `Question ${options.number}`
|
||||
}
|
||||
if (key === "chat:questions.typeAnswer") return "Type your answer..."
|
||||
if (key === "chat:questions.previous") return "Previous"
|
||||
if (key === "chat:questions.next") return "Next"
|
||||
|
|
@ -38,6 +42,9 @@ vi.mock("@src/i18n/setup", () => ({
|
|||
if (key === "chat:questions.questionNumberOfTotal" && options) {
|
||||
return `Question ${options.current} of ${options.total}`
|
||||
}
|
||||
if (key === "chat:questions.questionNumber" && options) {
|
||||
return `Question ${options.number}`
|
||||
}
|
||||
if (key === "chat:questions.typeAnswer") return "Type your answer..."
|
||||
if (key === "chat:questions.previous") return "Previous"
|
||||
if (key === "chat:questions.next") return "Next"
|
||||
|
|
@ -192,4 +199,44 @@ describe("MultiQuestionHandler", () => {
|
|||
|
||||
expect(mockOnSendResponse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle options correctly without copying to text area", () => {
|
||||
const questions = [{ text: "Color?", options: ["Red", "Blue"] }]
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MultiQuestionHandler questions={questions} onSendResponse={mockOnSendResponse} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
const redButton = screen.getByText("Red")
|
||||
fireEvent.click(redButton)
|
||||
|
||||
const textarea = screen.getByPlaceholderText("Type your answer...")
|
||||
expect(textarea).toHaveValue("")
|
||||
|
||||
const finishButton = screen.getByText("Finish")
|
||||
fireEvent.click(finishButton)
|
||||
|
||||
expect(mockOnSendResponse).toHaveBeenCalledWith("Question: Color?\nAnswer: Red")
|
||||
})
|
||||
|
||||
it("should handle both option and text answer", () => {
|
||||
const questions = [{ text: "Color?", options: ["Red", "Blue"] }]
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MultiQuestionHandler questions={questions} onSendResponse={mockOnSendResponse} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
const redButton = screen.getByText("Red")
|
||||
fireEvent.click(redButton)
|
||||
|
||||
const textarea = screen.getByPlaceholderText("Type your answer...")
|
||||
fireEvent.change(textarea, { target: { value: "very dark" } })
|
||||
|
||||
const finishButton = screen.getByText("Finish")
|
||||
fireEvent.click(finishButton)
|
||||
|
||||
expect(mockOnSendResponse).toHaveBeenCalledWith("Question: Color?\nAnswer: Red: very dark")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
includeCurrentTime,
|
||||
includeCurrentCost,
|
||||
maxGitStatusFiles,
|
||||
showQuestionsOneByOne,
|
||||
} = cachedState
|
||||
|
||||
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
|
||||
|
|
@ -426,6 +427,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
enterBehavior: enterBehavior ?? "send",
|
||||
includeCurrentTime: includeCurrentTime ?? true,
|
||||
includeCurrentCost: includeCurrentCost ?? true,
|
||||
showQuestionsOneByOne: showQuestionsOneByOne ?? false,
|
||||
maxGitStatusFiles: maxGitStatusFiles ?? 0,
|
||||
profileThresholds,
|
||||
imageGenerationProvider,
|
||||
|
|
@ -912,6 +914,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
<UISettings
|
||||
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
|
||||
enterBehavior={enterBehavior ?? "send"}
|
||||
showQuestionsOneByOne={showQuestionsOneByOne ?? false}
|
||||
setCachedStateField={setCachedStateField}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
|
|||
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
|
||||
reasoningBlockCollapsed: boolean
|
||||
enterBehavior: "send" | "newline"
|
||||
showQuestionsOneByOne: boolean
|
||||
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
|
||||
}
|
||||
|
||||
export const UISettings = ({
|
||||
reasoningBlockCollapsed,
|
||||
enterBehavior,
|
||||
showQuestionsOneByOne,
|
||||
setCachedStateField,
|
||||
...props
|
||||
}: UISettingsProps) => {
|
||||
|
|
@ -48,6 +50,15 @@ export const UISettings = ({
|
|||
})
|
||||
}
|
||||
|
||||
const handleShowQuestionsOneByOneChange = (value: boolean) => {
|
||||
setCachedStateField("showQuestionsOneByOne", value)
|
||||
|
||||
// Track telemetry event
|
||||
telemetryClient.capture("ui_settings_show_questions_one_by_one_changed", {
|
||||
enabled: value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<SectionHeader>{t("settings:sections.ui")}</SectionHeader>
|
||||
|
|
@ -91,6 +102,24 @@ export const UISettings = ({
|
|||
</div>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
{/* Show Questions One By One Setting */}
|
||||
<SearchableSetting
|
||||
settingId="ui-show-questions-one-by-one"
|
||||
section="ui"
|
||||
label={t("settings:ui.showQuestionsOneByOne.label")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<VSCodeCheckbox
|
||||
checked={showQuestionsOneByOne}
|
||||
onChange={(e: any) => handleShowQuestionsOneByOneChange(e.target.checked)}
|
||||
data-testid="show-questions-one-by-one-checkbox">
|
||||
<span className="font-medium">{t("settings:ui.showQuestionsOneByOne.label")}</span>
|
||||
</VSCodeCheckbox>
|
||||
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
|
||||
{t("settings:ui.showQuestionsOneByOne.description")}
|
||||
</div>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ describe("UISettings", () => {
|
|||
const defaultProps = {
|
||||
reasoningBlockCollapsed: false,
|
||||
enterBehavior: "send" as const,
|
||||
showQuestionsOneByOne: false,
|
||||
setCachedStateField: vi.fn(),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export const AutosizeTextarea = React.forwardRef<AutosizeTextAreaRef, AutosizeTe
|
|||
value={value}
|
||||
ref={textAreaRef}
|
||||
className={cn(
|
||||
"flex w-full rounded-xs ring-offset-background placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50 scrollbar-hide",
|
||||
"flex w-full rounded ring-offset-background placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50 scrollbar-hide",
|
||||
"border-[var(--vscode-input-border,var(--vscode-input-background))] focus-visible:border-vscode-focusBorder",
|
||||
"bg-vscode-input-background",
|
||||
"text-vscode-input-foreground",
|
||||
|
|
|
|||
|
|
@ -167,6 +167,8 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setIncludeCurrentTime: (value: boolean) => void
|
||||
includeCurrentCost?: boolean
|
||||
setIncludeCurrentCost: (value: boolean) => void
|
||||
showQuestionsOneByOne?: boolean
|
||||
setShowQuestionsOneByOne: (value: boolean) => void
|
||||
}
|
||||
|
||||
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||
|
|
@ -279,6 +281,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
openRouterImageGenerationSelectedModel: "",
|
||||
includeCurrentTime: true,
|
||||
includeCurrentCost: true,
|
||||
showQuestionsOneByOne: false,
|
||||
})
|
||||
|
||||
const [didHydrateState, setDidHydrateState] = useState(false)
|
||||
|
|
@ -301,6 +304,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
const [prevCloudIsAuthenticated, setPrevCloudIsAuthenticated] = useState(false)
|
||||
const [includeCurrentTime, setIncludeCurrentTime] = useState(true)
|
||||
const [includeCurrentCost, setIncludeCurrentCost] = useState(true)
|
||||
const [showQuestionsOneByOne, setShowQuestionsOneByOne] = useState(false)
|
||||
|
||||
const setListApiConfigMeta = useCallback(
|
||||
(value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
|
||||
|
|
@ -346,6 +350,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
if ((newState as any).includeCurrentCost !== undefined) {
|
||||
setIncludeCurrentCost((newState as any).includeCurrentCost)
|
||||
}
|
||||
// Update showQuestionsOneByOne if present in state message
|
||||
if ((newState as any).showQuestionsOneByOne !== undefined) {
|
||||
setShowQuestionsOneByOne((newState as any).showQuestionsOneByOne)
|
||||
}
|
||||
// Handle marketplace data if present in state message
|
||||
if (newState.marketplaceItems !== undefined) {
|
||||
setMarketplaceItems(newState.marketplaceItems)
|
||||
|
|
@ -596,6 +604,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setIncludeCurrentTime,
|
||||
includeCurrentCost,
|
||||
setIncludeCurrentCost,
|
||||
showQuestionsOneByOne,
|
||||
setShowQuestionsOneByOne,
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@
|
|||
},
|
||||
"questions": {
|
||||
"hasQuestion": "Roo has a question",
|
||||
"questionNumber": "Question {{number}}",
|
||||
"questionNumberOfTotal": "Question {{current}} of {{total}}",
|
||||
"typeAnswer": "Type your answer here...",
|
||||
"previous": "Previous",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue