diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 0c87655fc0c..eaec2ad8865 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -174,3 +174,19 @@ export const tokenUsageSchema = z.object({ }) export type TokenUsage = z.infer + +/** + * QueuedMessage + */ + +/** + * Represents a message that is queued to be sent when sending is enabled + */ +export interface QueuedMessage { + /** Unique identifier for the queued message */ + id: string + /** The text content of the message */ + text: string + /** Array of image data URLs attached to the message */ + images: string[] +} diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 682514b0faf..4322b9f5504 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -169,6 +169,8 @@ const lmStudioSchema = baseProviderSettingsSchema.extend({ const geminiSchema = apiModelIdProviderModelSchema.extend({ geminiApiKey: z.string().optional(), googleGeminiBaseUrl: z.string().optional(), + enableUrlContext: z.boolean().optional(), + enableGrounding: z.boolean().optional(), }) const geminiCliSchema = apiModelIdProviderModelSchema.extend({ diff --git a/src/api/providers/__tests__/bedrock-reasoning.spec.ts b/src/api/providers/__tests__/bedrock-reasoning.spec.ts index f8d9beb0eb7..24affcd1ad6 100644 --- a/src/api/providers/__tests__/bedrock-reasoning.spec.ts +++ b/src/api/providers/__tests__/bedrock-reasoning.spec.ts @@ -313,7 +313,11 @@ describe("AwsBedrockHandler - Extended Thinking", () => { expect(BedrockRuntimeClient).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - token: { token: "test-api-key-token" }, + credentials: { + accessKeyId: "bedrock-user", + secretAccessKey: "bedrock-pwd", + sessionToken: "test-api-key-token", + }, authSchemePreference: ["httpBearerAuth"], }), ) diff --git a/src/api/providers/__tests__/gemini-handler.spec.ts b/src/api/providers/__tests__/gemini-handler.spec.ts new file mode 100644 index 00000000000..7c61639cfd6 --- /dev/null +++ b/src/api/providers/__tests__/gemini-handler.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi } from "vitest" +import { t } from "i18next" +import { GeminiHandler } from "../gemini" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("GeminiHandler backend support", () => { + it("passes tools for URL context and grounding in config", async () => { + const options = { + apiProvider: "gemini", + enableUrlContext: true, + enableGrounding: true, + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + const stub = vi.fn().mockReturnValue((async function* () {})()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + await handler.createMessage("instr", [] as any).next() + const config = stub.mock.calls[0][0].config + expect(config.tools).toEqual([{ urlContext: {} }, { googleSearch: {} }]) + }) + + it("completePrompt passes config overrides without tools when URL context and grounding disabled", async () => { + const options = { + apiProvider: "gemini", + enableUrlContext: false, + enableGrounding: false, + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + const stub = vi.fn().mockResolvedValue({ text: "ok" }) + // @ts-ignore access private client + handler["client"].models.generateContent = stub + const res = await handler.completePrompt("hi") + expect(res).toBe("ok") + const promptConfig = stub.mock.calls[0][0].config + expect(promptConfig.tools).toBeUndefined() + }) + + describe("error scenarios", () => { + it("should handle grounding metadata extraction failure gracefully", async () => { + const options = { + apiProvider: "gemini", + enableGrounding: true, + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockStream = async function* () { + yield { + candidates: [ + { + groundingMetadata: { + // Invalid structure - missing groundingChunks + }, + content: { parts: [{ text: "test response" }] }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, + } + } + + const stub = vi.fn().mockReturnValue(mockStream()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + const messages = [] + for await (const chunk of handler.createMessage("test", [] as any)) { + messages.push(chunk) + } + + // Should still return the main content without sources + expect(messages.some((m) => m.type === "text" && m.text === "test response")).toBe(true) + expect(messages.some((m) => m.type === "text" && m.text?.includes("Sources:"))).toBe(false) + }) + + it("should handle malformed grounding metadata", async () => { + const options = { + apiProvider: "gemini", + enableGrounding: true, + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockStream = async function* () { + yield { + candidates: [ + { + groundingMetadata: { + groundingChunks: [ + { web: null }, // Missing URI + { web: { uri: "https://example.com" } }, // Valid + {}, // Missing web property entirely + ], + }, + content: { parts: [{ text: "test response" }] }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, + } + } + + const stub = vi.fn().mockReturnValue(mockStream()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + const messages = [] + for await (const chunk of handler.createMessage("test", [] as any)) { + messages.push(chunk) + } + + // Should only include valid citations + const sourceMessage = messages.find((m) => m.type === "text" && m.text?.includes("[2]")) + expect(sourceMessage).toBeDefined() + if (sourceMessage && "text" in sourceMessage) { + expect(sourceMessage.text).toContain("https://example.com") + expect(sourceMessage.text).not.toContain("[1]") + expect(sourceMessage.text).not.toContain("[3]") + } + }) + + it("should handle API errors when tools are enabled", async () => { + const options = { + apiProvider: "gemini", + enableUrlContext: true, + enableGrounding: true, + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockError = new Error("API rate limit exceeded") + const stub = vi.fn().mockRejectedValue(mockError) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + await expect(async () => { + const generator = handler.createMessage("test", [] as any) + await generator.next() + }).rejects.toThrow(t("common:errors.gemini.generate_stream", { error: "API rate limit exceeded" })) + }) + }) +}) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 8a7fd24fe36..812c1ae1a64 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { type ModelInfo, geminiDefaultModelId } from "@roo-code/types" +import { t } from "i18next" import { GeminiHandler } from "../gemini" const GEMINI_20_FLASH_THINKING_NAME = "gemini-2.0-flash-thinking-exp-1219" @@ -129,7 +130,7 @@ describe("GeminiHandler", () => { ;(handler["client"].models.generateContent as any).mockRejectedValue(mockError) await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Gemini completion error: Gemini API error", + t("common:errors.gemini.generate_complete_prompt", { error: "Gemini API error" }), ) }) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 8e9add524d3..d147e79ba8c 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -7,6 +7,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { ApiStreamChunk } from "../../transform/stream" +import { t } from "i18next" import { VertexHandler } from "../vertex" describe("VertexHandler", () => { @@ -105,7 +106,7 @@ describe("VertexHandler", () => { ;(handler["client"].models.generateContent as any).mockRejectedValue(mockError) await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Gemini completion error: Vertex API error", + t("common:errors.gemini.generate_complete_prompt", { error: "Vertex API error" }), ) }) diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 76e502e6c7d..4f08ef7e92f 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -224,7 +224,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH if (this.options.awsUseApiKey && this.options.awsApiKey) { // Use API key/token-based authentication if enabled and API key is set - clientConfig.token = { token: this.options.awsApiKey } + clientConfig.credentials = { + accessKeyId: "bedrock-user", + secretAccessKey: "bedrock-pwd", + sessionToken: this.options.awsApiKey, + } clientConfig.authSchemePreference = ["httpBearerAuth"] // Otherwise there's no end of credential problems. } else if (this.options.awsUseProfile && this.options.awsProfile) { // Use profile-based credentials if enabled and profile is set diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 6765c8676d8..5e547edbdc6 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -4,6 +4,7 @@ import { type GenerateContentResponseUsageMetadata, type GenerateContentParameters, type GenerateContentConfig, + type GroundingMetadata, } from "@google/genai" import type { JWTInput } from "google-auth-library" @@ -13,6 +14,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { safeJsonParse } from "../../shared/safeJsonParse" import { convertAnthropicContentToGemini, convertAnthropicMessageToGemini } from "../transform/gemini-format" +import { t } from "i18next" import type { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -67,72 +69,103 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl const contents = messages.map(convertAnthropicMessageToGemini) + const tools: GenerateContentConfig["tools"] = [] + if (this.options.enableUrlContext) { + tools.push({ urlContext: {} }) + } + + if (this.options.enableGrounding) { + tools.push({ googleSearch: {} }) + } + const config: GenerateContentConfig = { systemInstruction, httpOptions: this.options.googleGeminiBaseUrl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined, thinkingConfig, maxOutputTokens: this.options.modelMaxTokens ?? maxTokens ?? undefined, temperature: this.options.modelTemperature ?? 0, + ...(tools.length > 0 ? { tools } : {}), } const params: GenerateContentParameters = { model, contents, config } - const result = await this.client.models.generateContentStream(params) + try { + const result = await this.client.models.generateContentStream(params) + + let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined + let pendingGroundingMetadata: GroundingMetadata | undefined - let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined + for await (const chunk of result) { + // Process candidates and their parts to separate thoughts from content + if (chunk.candidates && chunk.candidates.length > 0) { + const candidate = chunk.candidates[0] - for await (const chunk of result) { - // Process candidates and their parts to separate thoughts from content - if (chunk.candidates && chunk.candidates.length > 0) { - const candidate = chunk.candidates[0] - if (candidate.content && candidate.content.parts) { - for (const part of candidate.content.parts) { - if (part.thought) { - // This is a thinking/reasoning part - if (part.text) { - yield { type: "reasoning", text: part.text } - } - } else { - // This is regular content - if (part.text) { - yield { type: "text", text: part.text } + if (candidate.groundingMetadata) { + pendingGroundingMetadata = candidate.groundingMetadata + } + + if (candidate.content && candidate.content.parts) { + for (const part of candidate.content.parts) { + if (part.thought) { + // This is a thinking/reasoning part + if (part.text) { + yield { type: "reasoning", text: part.text } + } + } else { + // This is regular content + if (part.text) { + yield { type: "text", text: part.text } + } } } } } - } - // Fallback to the original text property if no candidates structure - else if (chunk.text) { - yield { type: "text", text: chunk.text } + // Fallback to the original text property if no candidates structure + else if (chunk.text) { + yield { type: "text", text: chunk.text } + } + + if (chunk.usageMetadata) { + lastUsageMetadata = chunk.usageMetadata + } } - if (chunk.usageMetadata) { - lastUsageMetadata = chunk.usageMetadata + if (pendingGroundingMetadata) { + const citations = this.extractCitationsOnly(pendingGroundingMetadata) + if (citations) { + yield { type: "text", text: `\n\n${t("common:errors.gemini.sources")} ${citations}` } + } } - } - if (lastUsageMetadata) { - const inputTokens = lastUsageMetadata.promptTokenCount ?? 0 - const outputTokens = lastUsageMetadata.candidatesTokenCount ?? 0 - const cacheReadTokens = lastUsageMetadata.cachedContentTokenCount - const reasoningTokens = lastUsageMetadata.thoughtsTokenCount - - yield { - type: "usage", - inputTokens, - outputTokens, - cacheReadTokens, - reasoningTokens, - totalCost: this.calculateCost({ info, inputTokens, outputTokens, cacheReadTokens }), + if (lastUsageMetadata) { + const inputTokens = lastUsageMetadata.promptTokenCount ?? 0 + const outputTokens = lastUsageMetadata.candidatesTokenCount ?? 0 + const cacheReadTokens = lastUsageMetadata.cachedContentTokenCount + const reasoningTokens = lastUsageMetadata.thoughtsTokenCount + + yield { + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens, + reasoningTokens, + totalCost: this.calculateCost({ info, inputTokens, outputTokens, cacheReadTokens }), + } } + } catch (error) { + if (error instanceof Error) { + throw new Error(t("common:errors.gemini.generate_stream", { error: error.message })) + } + + throw error } } override getModel() { const modelId = this.options.apiModelId let id = modelId && modelId in geminiModels ? (modelId as GeminiModelId) : geminiDefaultModelId - const info: ModelInfo = geminiModels[id] + let info: ModelInfo = geminiModels[id] const params = getModelParams({ format: "gemini", modelId: id, model: info, settings: this.options }) // The `:thinking` suffix indicates that the model is a "Hybrid" @@ -142,25 +175,69 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl return { id: id.endsWith(":thinking") ? id.replace(":thinking", "") : id, info, ...params } } + private extractCitationsOnly(groundingMetadata?: GroundingMetadata): string | null { + const chunks = groundingMetadata?.groundingChunks + + if (!chunks) { + return null + } + + const citationLinks = chunks + .map((chunk, i) => { + const uri = chunk.web?.uri + if (uri) { + return `[${i + 1}](${uri})` + } + return null + }) + .filter((link): link is string => link !== null) + + if (citationLinks.length > 0) { + return citationLinks.join(", ") + } + + return null + } + async completePrompt(prompt: string): Promise { try { const { id: model } = this.getModel() + const tools: GenerateContentConfig["tools"] = [] + if (this.options.enableUrlContext) { + tools.push({ urlContext: {} }) + } + if (this.options.enableGrounding) { + tools.push({ googleSearch: {} }) + } + const promptConfig: GenerateContentConfig = { + httpOptions: this.options.googleGeminiBaseUrl + ? { baseUrl: this.options.googleGeminiBaseUrl } + : undefined, + temperature: this.options.modelTemperature ?? 0, + ...(tools.length > 0 ? { tools } : {}), + } + const result = await this.client.models.generateContent({ model, contents: [{ role: "user", parts: [{ text: prompt }] }], - config: { - httpOptions: this.options.googleGeminiBaseUrl - ? { baseUrl: this.options.googleGeminiBaseUrl } - : undefined, - temperature: this.options.modelTemperature ?? 0, - }, + config: promptConfig, }) - return result.text ?? "" + let text = result.text ?? "" + + const candidate = result.candidates?.[0] + if (candidate?.groundingMetadata) { + const citations = this.extractCitationsOnly(candidate.groundingMetadata) + if (citations) { + text += `\n\n${t("common:errors.gemini.sources")} ${citations}` + } + } + + return text } catch (error) { if (error instanceof Error) { - throw new Error(`Gemini completion error: ${error.message}`) + throw new Error(t("common:errors.gemini.generate_complete_prompt", { error: error.message })) } throw error diff --git a/src/core/diff/strategies/multi-file-search-replace.ts b/src/core/diff/strategies/multi-file-search-replace.ts index d875d723a15..5ec223477cf 100644 --- a/src/core/diff/strategies/multi-file-search-replace.ts +++ b/src/core/diff/strategies/multi-file-search-replace.ts @@ -93,7 +93,7 @@ export class MultiFileSearchReplaceDiffStrategy implements DiffStrategy { getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { return `## apply_diff -Description: Request to apply targeted modifications to one or more files by searching for specific sections of content and replacing them. This tool supports both single-file and multi-file operations, allowing you to make changes across multiple files in a single request. +Description: Request to apply PRECISE, TARGETED modifications to one or more files by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code. This tool supports both single-file and multi-file operations, allowing you to make changes across multiple files in a single request. **IMPORTANT: You MUST use multiple files in a single operation whenever possible to maximize efficiency and minimize back-and-forth.** diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts index b90ef4072d5..d4c14b169f2 100644 --- a/src/core/diff/strategies/multi-search-replace.ts +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -92,7 +92,7 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy { getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { return `## apply_diff -Description: Request to apply targeted modifications to an existing file by searching for specific sections of content and replacing them. This tool is ideal for precise, surgical edits when you know the exact content to change. It helps maintain proper indentation and formatting. +Description: Request to apply PRECISE, TARGETED modifications to an existing file by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code. You can perform multiple distinct search and replace operations within a single \`apply_diff\` call by providing multiple SEARCH/REPLACE blocks in the \`diff\` parameter. This is the preferred way to make several targeted changes efficiently. The SEARCH section must exactly match existing content including whitespace and indentation. If you're not confident in the exact content to search for, use the read_file tool first to get the exact content. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap index 4390b955190..7a60f1d403b 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap @@ -167,7 +167,7 @@ Examples: ## apply_diff -Description: Request to apply targeted modifications to an existing file by searching for specific sections of content and replacing them. This tool is ideal for precise, surgical edits when you know the exact content to change. It helps maintain proper indentation and formatting. +Description: Request to apply PRECISE, TARGETED modifications to an existing file by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code. You can perform multiple distinct search and replace operations within a single `apply_diff` call by providing multiple SEARCH/REPLACE blocks in the `diff` parameter. This is the preferred way to make several targeted changes efficiently. The SEARCH section must exactly match existing content including whitespace and indentation. If you're not confident in the exact content to search for, use the read_file tool first to get the exact content. @@ -577,7 +577,7 @@ RULES - Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using apply_diff or write_to_file to make informed changes. - When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser. -- For editing files, you have access to these tools: apply_diff (for replacing lines in existing files), write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files), search_and_replace (for finding and replacing individual pieces of text). +- For editing files, you have access to these tools: apply_diff (for surgical edits - targeted changes to specific lines or functions), write_to_file (for creating new files or complete file rewrites), insert_content (for adding lines to files), search_and_replace (for finding and replacing individual pieces of text). - The insert_content tool adds lines of text to files at a specific line number, such as adding a new function to a JavaScript file or inserting a new route in a Python file. Use line number 0 to append at the end of the file, or any positive number to insert before that line. - The search_and_replace tool finds and replaces text or regex in files. This tool allows you to search for a specific regex pattern or text and replace it with another value. Be cautious when using this tool to ensure you are replacing the correct text. It can support multiple operations at once. - You should always prefer using other editing tools over write_to_file when making changes to existing files since write_to_file is much slower and cannot handle large files. diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 5828568ac48..a5eaf23ce08 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -8,7 +8,7 @@ function getEditingInstructions(diffStrategy?: DiffStrategy): string { // Collect available editing tools if (diffStrategy) { availableTools.push( - "apply_diff (for replacing lines in existing files)", + "apply_diff (for surgical edits - targeted changes to specific lines or functions)", "write_to_file (for creating new files or complete file rewrites)", ) } else { diff --git a/src/core/sliding-window/__tests__/sliding-window.spec.ts b/src/core/sliding-window/__tests__/sliding-window.spec.ts index 393d50307e4..0f2c70c81bc 100644 --- a/src/core/sliding-window/__tests__/sliding-window.spec.ts +++ b/src/core/sliding-window/__tests__/sliding-window.spec.ts @@ -250,7 +250,6 @@ describe("Sliding Window", () => { { role: "assistant", content: "Fourth message" }, { role: "user", content: "Fifth message" }, ] - it("should not truncate if tokens are below max tokens threshold", async () => { const modelInfo = createModelInfo(100000, 30000) const dynamicBuffer = modelInfo.contextWindow * TOKEN_BUFFER_PERCENTAGE // 10000 diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 39bc1df8f8b..cbe2c032b40 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -81,6 +81,11 @@ "stoppedWithReason": "Claude Code s'ha aturat per la raó: {{reason}}", "apiKeyModelPlanMismatch": "Les claus API i els plans de subscripció permeten models diferents. Assegura't que el model seleccionat estigui inclòs al teu pla." }, + "gemini": { + "generate_stream": "Error del flux de context de generació de Gemini: {{error}}", + "generate_complete_prompt": "Error de finalització de Gemini: {{error}}", + "sources": "Fonts:" + }, "mode_import_failed": "Ha fallat la importació del mode: {{error}}" }, "warnings": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index fbd800f6028..95d315e1038 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Claude Code Prozess wurde mit Code {{exitCode}} beendet. Fehlerausgabe: {{output}}", "stoppedWithReason": "Claude Code wurde mit Grund gestoppt: {{reason}}", "apiKeyModelPlanMismatch": "API-Schlüssel und Abonnement-Pläne erlauben verschiedene Modelle. Stelle sicher, dass das ausgewählte Modell in deinem Plan enthalten ist." + }, + "gemini": { + "generate_stream": "Fehler beim Generieren des Kontext-Streams von Gemini: {{error}}", + "generate_complete_prompt": "Fehler bei der Vervollständigung durch Gemini: {{error}}", + "sources": "Quellen:" } }, "warnings": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index db6341c312e..4150e12f848 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Claude Code process exited with code {{exitCode}}. Error output: {{output}}", "stoppedWithReason": "Claude Code stopped with reason: {{reason}}", "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." + }, + "gemini": { + "generate_stream": "Gemini generate context stream error: {{error}}", + "generate_complete_prompt": "Gemini completion error: {{error}}", + "sources": "Sources:" } }, "warnings": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index cc04abfdaec..d47b95be7e6 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "El proceso de Claude Code terminó con código {{exitCode}}. Salida de error: {{output}}", "stoppedWithReason": "Claude Code se detuvo por la razón: {{reason}}", "apiKeyModelPlanMismatch": "Las claves API y los planes de suscripción permiten diferentes modelos. Asegúrate de que el modelo seleccionado esté incluido en tu plan." + }, + "gemini": { + "generate_stream": "Error del stream de contexto de generación de Gemini: {{error}}", + "generate_complete_prompt": "Error de finalización de Gemini: {{error}}", + "sources": "Fuentes:" } }, "warnings": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 73f3e3d3969..e239358bbfc 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Le processus Claude Code s'est terminé avec le code {{exitCode}}. Sortie d'erreur : {{output}}", "stoppedWithReason": "Claude Code s'est arrêté pour la raison : {{reason}}", "apiKeyModelPlanMismatch": "Les clés API et les plans d'abonnement permettent différents modèles. Assurez-vous que le modèle sélectionné est inclus dans votre plan." + }, + "gemini": { + "generate_stream": "Erreur du flux de contexte de génération Gemini : {{error}}", + "generate_complete_prompt": "Erreur d'achèvement de Gemini : {{error}}", + "sources": "Sources :" } }, "warnings": { diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 03f74e1af52..d5ba036f632 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Claude Code प्रक्रिया कोड {{exitCode}} के साथ समाप्त हुई। त्रुटि आउटपुट: {{output}}", "stoppedWithReason": "Claude Code इस कारण से रुका: {{reason}}", "apiKeyModelPlanMismatch": "API कुंजी और सब्सक्रिप्शन प्लान अलग-अलग मॉडल की अनुमति देते हैं। सुनिश्चित करें कि चयनित मॉडल आपकी योजना में शामिल है।" + }, + "gemini": { + "generate_stream": "जेमिनी जनरेट कॉन्टेक्स्ट स्ट्रीम त्रुटि: {{error}}", + "generate_complete_prompt": "जेमिनी समापन त्रुटि: {{error}}", + "sources": "स्रोत:" } }, "warnings": { diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 822341f5295..b261f69e242 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Proses Claude Code keluar dengan kode {{exitCode}}. Output error: {{output}}", "stoppedWithReason": "Claude Code berhenti karena alasan: {{reason}}", "apiKeyModelPlanMismatch": "Kunci API dan paket berlangganan memungkinkan model yang berbeda. Pastikan model yang dipilih termasuk dalam paket Anda." + }, + "gemini": { + "generate_stream": "Kesalahan aliran konteks pembuatan Gemini: {{error}}", + "generate_complete_prompt": "Kesalahan penyelesaian Gemini: {{error}}", + "sources": "Sumber:" } }, "warnings": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 7ae45cc4c53..f5e15398edd 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Il processo Claude Code è terminato con codice {{exitCode}}. Output di errore: {{output}}", "stoppedWithReason": "Claude Code si è fermato per il motivo: {{reason}}", "apiKeyModelPlanMismatch": "Le chiavi API e i piani di abbonamento consentono modelli diversi. Assicurati che il modello selezionato sia incluso nel tuo piano." + }, + "gemini": { + "generate_stream": "Errore del flusso di contesto di generazione Gemini: {{error}}", + "generate_complete_prompt": "Errore di completamento Gemini: {{error}}", + "sources": "Fonti:" } }, "warnings": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index da8124b48ca..9b43f64bcf4 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Claude Code プロセスがコード {{exitCode}} で終了しました。エラー出力:{{output}}", "stoppedWithReason": "Claude Code が理由により停止しました:{{reason}}", "apiKeyModelPlanMismatch": "API キーとサブスクリプションプランでは異なるモデルが利用可能です。選択したモデルがプランに含まれていることを確認してください。" + }, + "gemini": { + "generate_stream": "Gemini 生成コンテキスト ストリーム エラー: {{error}}", + "generate_complete_prompt": "Gemini 完了エラー: {{error}}", + "sources": "ソース:" } }, "warnings": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index a95908ffecb..2bfbacad320 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Claude Code 프로세스가 코드 {{exitCode}}로 종료되었습니다. 오류 출력: {{output}}", "stoppedWithReason": "Claude Code가 다음 이유로 중지되었습니다: {{reason}}", "apiKeyModelPlanMismatch": "API 키와 구독 플랜에서 다른 모델을 허용합니다. 선택한 모델이 플랜에 포함되어 있는지 확인하세요." + }, + "gemini": { + "generate_stream": "Gemini 생성 컨텍스트 스트림 오류: {{error}}", + "generate_complete_prompt": "Gemini 완료 오류: {{error}}", + "sources": "출처:" } }, "warnings": { diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index ac7df81e424..645a371754f 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Claude Code proces beëindigd met code {{exitCode}}. Foutuitvoer: {{output}}", "stoppedWithReason": "Claude Code gestopt om reden: {{reason}}", "apiKeyModelPlanMismatch": "API-sleutels en abonnementsplannen staan verschillende modellen toe. Zorg ervoor dat het geselecteerde model is opgenomen in je plan." + }, + "gemini": { + "generate_stream": "Fout bij het genereren van contextstream door Gemini: {{error}}", + "generate_complete_prompt": "Fout bij het voltooien door Gemini: {{error}}", + "sources": "Bronnen:" } }, "warnings": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index e24960af89f..45251e1ab4c 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Proces Claude Code zakończył się kodem {{exitCode}}. Wyjście błędu: {{output}}", "stoppedWithReason": "Claude Code zatrzymał się z powodu: {{reason}}", "apiKeyModelPlanMismatch": "Klucze API i plany subskrypcji pozwalają na różne modele. Upewnij się, że wybrany model jest zawarty w twoim planie." + }, + "gemini": { + "generate_stream": "Błąd strumienia kontekstu generowania Gemini: {{error}}", + "generate_complete_prompt": "Błąd uzupełniania Gemini: {{error}}", + "sources": "Źródła:" } }, "warnings": { diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 6007beb41a3..29c951fb392 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -81,6 +81,11 @@ "processExitedWithError": "O processo Claude Code saiu com código {{exitCode}}. Saída de erro: {{output}}", "stoppedWithReason": "Claude Code parou pela razão: {{reason}}", "apiKeyModelPlanMismatch": "Chaves de API e planos de assinatura permitem modelos diferentes. Certifique-se de que o modelo selecionado esteja incluído no seu plano." + }, + "gemini": { + "generate_stream": "Erro de fluxo de contexto de geração do Gemini: {{error}}", + "generate_complete_prompt": "Erro de conclusão do Gemini: {{error}}", + "sources": "Fontes:" } }, "warnings": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 4d3daaf7431..5f8fdf34c17 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Процесс Claude Code завершился с кодом {{exitCode}}. Вывод ошибки: {{output}}", "stoppedWithReason": "Claude Code остановился по причине: {{reason}}", "apiKeyModelPlanMismatch": "API-ключи и планы подписки позволяют использовать разные модели. Убедитесь, что выбранная модель включена в ваш план." + }, + "gemini": { + "generate_stream": "Ошибка потока контекста генерации Gemini: {{error}}", + "generate_complete_prompt": "Ошибка завершения Gemini: {{error}}", + "sources": "Источники:" } }, "warnings": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index e2dfca734bc..c7feb38ef66 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Claude Code işlemi {{exitCode}} koduyla çıktı. Hata çıktısı: {{output}}", "stoppedWithReason": "Claude Code şu nedenle durdu: {{reason}}", "apiKeyModelPlanMismatch": "API anahtarları ve abonelik planları farklı modellere izin verir. Seçilen modelin planınıza dahil olduğundan emin olun." + }, + "gemini": { + "generate_stream": "Gemini oluşturma bağlam akışı hatası: {{error}}", + "generate_complete_prompt": "Gemini tamamlama hatası: {{error}}", + "sources": "Kaynaklar:" } }, "warnings": { diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 15e4ef8b779..84b8b409dc1 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -77,6 +77,11 @@ "processExitedWithError": "Tiến trình Claude Code thoát với mã {{exitCode}}. Đầu ra lỗi: {{output}}", "stoppedWithReason": "Claude Code dừng lại vì lý do: {{reason}}", "apiKeyModelPlanMismatch": "Khóa API và gói đăng ký cho phép các mô hình khác nhau. Đảm bảo rằng mô hình đã chọn được bao gồm trong gói của bạn." + }, + "gemini": { + "generate_stream": "Lỗi luồng ngữ cảnh tạo Gemini: {{error}}", + "generate_complete_prompt": "Lỗi hoàn thành Gemini: {{error}}", + "sources": "Nguồn:" } }, "warnings": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index edbbb6ae8c7..7798a8bbdb1 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -82,6 +82,11 @@ "processExitedWithError": "Claude Code 进程退出,退出码:{{exitCode}}。错误输出:{{output}}", "stoppedWithReason": "Claude Code 停止,原因:{{reason}}", "apiKeyModelPlanMismatch": "API 密钥和订阅计划支持不同的模型。请确保所选模型包含在您的计划中。" + }, + "gemini": { + "generate_stream": "Gemini 生成上下文流错误:{{error}}", + "generate_complete_prompt": "Gemini 完成错误:{{error}}", + "sources": "来源:" } }, "warnings": { diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index e7887025f10..c6105c2bf9f 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -77,6 +77,11 @@ "stoppedWithReason": "Claude Code 停止,原因:{{reason}}", "apiKeyModelPlanMismatch": "API 金鑰和訂閱方案允許不同的模型。請確保所選模型包含在您的方案中。" }, + "gemini": { + "generate_stream": "Gemini 產生內容串流錯誤:{{error}}", + "generate_complete_prompt": "Gemini 完成錯誤:{{error}}", + "sources": "來源:" + }, "mode_import_failed": "匯入模式失敗:{{error}}" }, "warnings": { diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 4fe2ec8a14f..e387732197e 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -204,10 +204,6 @@ const ChatTextArea = forwardRef( }, [selectedType, searchQuery]) const handleEnhancePrompt = useCallback(() => { - if (sendingDisabled) { - return - } - const trimmedInput = inputValue.trim() if (trimmedInput) { @@ -216,7 +212,7 @@ const ChatTextArea = forwardRef( } else { setInputValue(t("chat:enhancePromptDescription")) } - }, [inputValue, sendingDisabled, setInputValue, t]) + }, [inputValue, setInputValue, t]) const allModes = useMemo(() => getAllModes(customModes), [customModes]) @@ -436,11 +432,9 @@ const ChatTextArea = forwardRef( if (event.key === "Enter" && !event.shiftKey && !isComposing) { event.preventDefault() - if (!sendingDisabled) { - // Reset history navigation state when sending - resetHistoryNavigation() - onSend() - } + // Always call onSend - let ChatView handle queueing when disabled + resetHistoryNavigation() + onSend() } if (event.key === "Backspace" && !isComposing) { @@ -488,7 +482,6 @@ const ChatTextArea = forwardRef( } }, [ - sendingDisabled, onSend, showContextMenu, searchQuery, @@ -1034,8 +1027,8 @@ const ChatTextArea = forwardRef( @@ -1059,8 +1050,8 @@ const ChatTextArea = forwardRef( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index efd2db856c0..3c4ffd3208a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -54,7 +54,9 @@ import AutoApproveMenu from "./AutoApproveMenu" import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" +import QueuedMessages from "./QueuedMessages" import { getLatestTodo } from "@roo/todo" +import { QueuedMessage } from "@roo-code/types" export interface ChatViewProps { isHidden: boolean @@ -154,6 +156,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) + const [messageQueue, setMessageQueue] = useState([]) + const isProcessingQueueRef = useRef(false) + const retryCountRef = useRef>(new Map()) + const MAX_RETRY_ATTEMPTS = 3 // we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed) const [clineAsk, setClineAsk] = useState(undefined) @@ -439,6 +445,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -538,47 +549,133 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - text = text.trim() - - if (text || images.length > 0) { - // Mark that user has responded - this prevents any pending auto-approvals - userRespondedRef.current = true - - if (messagesRef.current.length === 0) { - vscode.postMessage({ type: "newTask", text, images }) - } else if (clineAskRef.current) { - if (clineAskRef.current === "followup") { - markFollowUpAsAnswered() + (text: string, images: string[], fromQueue = false) => { + try { + text = text.trim() + + if (text || images.length > 0) { + if (sendingDisabled && !fromQueue) { + // Generate a more unique ID using timestamp + random component + const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + setMessageQueue((prev) => [...prev, { id: messageId, text, images }]) + setInputValue("") + setSelectedImages([]) + return } + // Mark that user has responded - this prevents any pending auto-approvals + userRespondedRef.current = true + + if (messagesRef.current.length === 0) { + vscode.postMessage({ type: "newTask", text, images }) + } else if (clineAskRef.current) { + if (clineAskRef.current === "followup") { + markFollowUpAsAnswered() + } - // Use clineAskRef.current - switch ( - clineAskRef.current // Use clineAskRef.current - ) { - case "followup": - case "tool": - case "browser_action_launch": - case "command": // User can provide feedback to a tool or command use. - case "command_output": // User can send input to command stdin. - case "use_mcp_server": - case "completion_result": // If this happens then the user has feedback for the completion result. - case "resume_task": - case "resume_completed_task": - case "mistake_limit_reached": - vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) - break - // There is no other case that a textfield should be enabled. + // Use clineAskRef.current + switch ( + clineAskRef.current // Use clineAskRef.current + ) { + case "followup": + case "tool": + case "browser_action_launch": + case "command": // User can provide feedback to a tool or command use. + case "command_output": // User can send input to command stdin. + case "use_mcp_server": + case "completion_result": // If this happens then the user has feedback for the completion result. + case "resume_task": + case "resume_completed_task": + case "mistake_limit_reached": + vscode.postMessage({ + type: "askResponse", + askResponse: "messageResponse", + text, + images, + }) + break + // There is no other case that a textfield should be enabled. + } + } else { + // This is a new message in an ongoing task. + vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) } - } - handleChatReset() + handleChatReset() + } + } catch (error) { + console.error("Error in handleSendMessage:", error) + // If this was a queued message, we should handle it differently + if (fromQueue) { + throw error // Re-throw to be caught by the queue processor + } + // For direct sends, we could show an error to the user + // but for now we'll just log it } }, - [handleChatReset, markFollowUpAsAnswered], // messagesRef and clineAskRef are stable + [handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable ) + useEffect(() => { + // Early return if conditions aren't met + // Also don't process queue if there's an API error (clineAsk === "api_req_failed") + if ( + sendingDisabled || + messageQueue.length === 0 || + isProcessingQueueRef.current || + clineAsk === "api_req_failed" + ) { + return + } + + // Mark as processing immediately to prevent race conditions + isProcessingQueueRef.current = true + + // Process the first message in the queue + const [nextMessage, ...remaining] = messageQueue + + // Update queue immediately to prevent duplicate processing + setMessageQueue(remaining) + + // Process the message + Promise.resolve() + .then(() => { + handleSendMessage(nextMessage.text, nextMessage.images, true) + // Clear retry count on success + retryCountRef.current.delete(nextMessage.id) + }) + .catch((error) => { + console.error("Failed to send queued message:", error) + + // Get current retry count + const retryCount = retryCountRef.current.get(nextMessage.id) || 0 + + // Only re-add if under retry limit + if (retryCount < MAX_RETRY_ATTEMPTS) { + retryCountRef.current.set(nextMessage.id, retryCount + 1) + // Re-add the message to the end of the queue + setMessageQueue((current) => [...current, nextMessage]) + } else { + console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`) + retryCountRef.current.delete(nextMessage.id) + } + }) + .finally(() => { + isProcessingQueueRef.current = false + }) + + // Cleanup function to handle component unmount + return () => { + isProcessingQueueRef.current = false + } + }, [sendingDisabled, messageQueue, handleSendMessage, clineAsk]) + const handleSetChatBoxMessage = useCallback( (text: string, images: string[]) => { // Avoid nested template literals by breaking down the logic @@ -594,6 +691,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Store refs in variables to avoid stale closure issues + const retryCountMap = retryCountRef.current + const isProcessingRef = isProcessingQueueRef + + return () => { + retryCountMap.clear() + isProcessingRef.current = false + } + }, []) + const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), []) // This logic depends on the useEffect[messages] above to set clineAsk, @@ -1630,7 +1739,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction +
{(showAnnouncement || showAnnouncementModal) && ( { @@ -1836,6 +1947,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction )} + setMessageQueue((prev) => prev.filter((_, i) => i !== index))} + onUpdate={(index, newText) => { + setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg))) + }} + /> void + onUpdate: (index: number, newText: string) => void +} + +const QueuedMessages: React.FC = ({ queue, onRemove, onUpdate }) => { + const { t } = useTranslation("chat") + const [editingStates, setEditingStates] = useState>({}) + + if (queue.length === 0) { + return null + } + + const getEditState = (messageId: string, currentText: string) => { + return editingStates[messageId] || { isEditing: false, value: currentText } + } + + const setEditState = (messageId: string, isEditing: boolean, value?: string) => { + setEditingStates((prev) => ({ + ...prev, + [messageId]: { isEditing, value: value ?? prev[messageId]?.value ?? "" }, + })) + } + + const handleSaveEdit = (index: number, messageId: string, newValue: string) => { + onUpdate(index, newValue) + setEditState(messageId, false) + } + + return ( +
+
{t("queuedMessages.title")}
+
+ {queue.map((message, index) => { + const editState = getEditState(message.id, message.text) + + return ( +
+
+
+ {editState.isEditing ? ( +