From a03cda04812e156f33c4d4fb9c07ddad91dd26f4 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Mon, 8 Sep 2025 16:28:42 +0100 Subject: [PATCH 01/32] First pass at separate upsell dialog --- .../src/components/chat/ShareButton.tsx | 58 ++----------- .../components/cloud/CloudUpsellDialog.tsx | 52 ++++++++++++ .../__tests__/CloudUpsellDialog.spec.tsx | 62 ++++++++++++++ .../hooks/__tests__/useCloudUpsell.spec.ts | 84 +++++++++++++++++++ webview-ui/src/hooks/useCloudUpsell.ts | 34 ++++++++ 5 files changed, 240 insertions(+), 50 deletions(-) create mode 100644 webview-ui/src/components/cloud/CloudUpsellDialog.tsx create mode 100644 webview-ui/src/components/cloud/__tests__/CloudUpsellDialog.spec.tsx create mode 100644 webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts create mode 100644 webview-ui/src/hooks/useCloudUpsell.ts diff --git a/webview-ui/src/components/chat/ShareButton.tsx b/webview-ui/src/components/chat/ShareButton.tsx index 4bcabb3a1c..fdc915274e 100644 --- a/webview-ui/src/components/chat/ShareButton.tsx +++ b/webview-ui/src/components/chat/ShareButton.tsx @@ -7,6 +7,8 @@ import { type HistoryItem, type ShareVisibility, TelemetryEventName } from "@roo import { vscode } from "@/utils/vscode" import { telemetryClient } from "@/utils/TelemetryClient" import { useExtensionState } from "@/context/ExtensionStateContext" +import { useCloudUpsell } from "@/hooks/useCloudUpsell" +import { CloudUpsellDialog } from "@/components/cloud/CloudUpsellDialog" import { Button, Popover, @@ -16,10 +18,6 @@ import { CommandList, CommandItem, CommandGroup, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, StandardTooltip, } from "@/components/ui" @@ -31,10 +29,10 @@ interface ShareButtonProps { export const ShareButton = ({ item, disabled = false, showLabel = false }: ShareButtonProps) => { const [shareDropdownOpen, setShareDropdownOpen] = useState(false) - const [connectModalOpen, setConnectModalOpen] = useState(false) const [shareSuccess, setShareSuccess] = useState<{ visibility: ShareVisibility; url: string } | null>(null) const { t } = useTranslation() const { sharingEnabled, cloudIsAuthenticated, cloudUserInfo } = useExtensionState() + const { isOpen: connectModalOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell() const wasUnauthenticatedRef = useRef(false) const initiatedAuthFromThisButtonRef = useRef(false) @@ -47,13 +45,13 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share if (initiatedAuthFromThisButtonRef.current) { // User just authenticated from this share button, send telemetry, close modal, and open the popover telemetryClient.capture(TelemetryEventName.ACCOUNT_CONNECT_SUCCESS) - setConnectModalOpen(false) + closeUpsell() setShareDropdownOpen(true) initiatedAuthFromThisButtonRef.current = false // Reset the flag } wasUnauthenticatedRef.current = false } - }, [cloudIsAuthenticated, sharingEnabled]) + }, [cloudIsAuthenticated, sharingEnabled, closeUpsell]) // Listen for share success messages from the extension useEffect(() => { @@ -95,14 +93,10 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share } const handleConnectToCloud = () => { - // Send telemetry for connect to cloud action - telemetryClient.capture(TelemetryEventName.SHARE_CONNECT_TO_CLOUD_CLICKED) - // Mark that authentication was initiated from this button initiatedAuthFromThisButtonRef.current = true - vscode.postMessage({ type: "rooCloudSignIn" }) + handleConnect() setShareDropdownOpen(false) - setConnectModalOpen(false) } const handleShareButtonClick = () => { @@ -111,7 +105,7 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share if (!cloudIsAuthenticated) { // Show modal for unauthenticated users - setConnectModalOpen(true) + openUpsell() } else { // Show popover for authenticated users setShareDropdownOpen(true) @@ -241,43 +235,7 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share )} {/* Connect to Cloud Modal */} - - - - - {t("cloud:cloudBenefitsTitle")} - - - -
-
-

- {t("cloud:cloudBenefitsSubtitle")} -

-
    -
  • - - {t("cloud:cloudBenefitSharing")} -
  • -
  • - - {t("cloud:cloudBenefitHistory")} -
  • -
  • - - {t("cloud:cloudBenefitMetrics")} -
  • -
-
- -
- -
-
-
-
+ ) } diff --git a/webview-ui/src/components/cloud/CloudUpsellDialog.tsx b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx new file mode 100644 index 0000000000..c814654859 --- /dev/null +++ b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next" +import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from "@/components/ui" + +interface CloudUpsellDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConnect: () => void +} + +export const CloudUpsellDialog = ({ open, onOpenChange, onConnect }: CloudUpsellDialogProps) => { + const { t } = useTranslation() + + return ( + + + + + {t("cloud:cloudBenefitsTitle")} + + + +
+
+

+ {t("cloud:cloudBenefitsSubtitle")} +

+
    +
  • + + {t("cloud:cloudBenefitSharing")} +
  • +
  • + + {t("cloud:cloudBenefitHistory")} +
  • +
  • + + {t("cloud:cloudBenefitMetrics")} +
  • +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/webview-ui/src/components/cloud/__tests__/CloudUpsellDialog.spec.tsx b/webview-ui/src/components/cloud/__tests__/CloudUpsellDialog.spec.tsx new file mode 100644 index 0000000000..52483fe47d --- /dev/null +++ b/webview-ui/src/components/cloud/__tests__/CloudUpsellDialog.spec.tsx @@ -0,0 +1,62 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { vi } from "vitest" +import { CloudUpsellDialog } from "../CloudUpsellDialog" + +// Mock the useTranslation hook +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "cloud:cloudBenefitsTitle": "Connect to Roo Cloud", + "cloud:cloudBenefitsSubtitle": "Get more from your experience", + "cloud:cloudBenefitSharing": "Share tasks with your team", + "cloud:cloudBenefitHistory": "Access conversation history", + "cloud:cloudBenefitMetrics": "View usage metrics", + "cloud:connect": "Connect to Cloud", + } + return translations[key] || key + }, + }), +})) + +describe("CloudUpsellDialog", () => { + const mockOnOpenChange = vi.fn() + const mockOnConnect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders dialog when open", () => { + render() + + expect(screen.getByText("Connect to Roo Cloud")).toBeInTheDocument() + expect(screen.getByText("Get more from your experience")).toBeInTheDocument() + expect(screen.getByText("Share tasks with your team")).toBeInTheDocument() + expect(screen.getByText("Access conversation history")).toBeInTheDocument() + expect(screen.getByText("View usage metrics")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Connect to Cloud" })).toBeInTheDocument() + }) + + it("does not render dialog when closed", () => { + render() + + expect(screen.queryByText("Connect to Roo Cloud")).not.toBeInTheDocument() + }) + + it("calls onConnect when connect button is clicked", () => { + render() + + const connectButton = screen.getByRole("button", { name: "Connect to Cloud" }) + fireEvent.click(connectButton) + + expect(mockOnConnect).toHaveBeenCalledTimes(1) + }) + + it("renders all three benefits with bullet points", () => { + render() + + const bullets = screen.getAllByText("•") + expect(bullets).toHaveLength(3) + }) +}) diff --git a/webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts b/webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts new file mode 100644 index 0000000000..f3490c12b8 --- /dev/null +++ b/webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts @@ -0,0 +1,84 @@ +import { renderHook, act } from "@testing-library/react" +import { vi } from "vitest" +import { useCloudUpsell } from "../useCloudUpsell" +import { TelemetryEventName } from "@roo-code/types" + +// Mock vscode +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock telemetryClient +vi.mock("@/utils/TelemetryClient", () => ({ + telemetryClient: { + capture: vi.fn(), + }, +})) + +describe("useCloudUpsell", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("initializes with isOpen as false", () => { + const { result } = renderHook(() => useCloudUpsell()) + expect(result.current.isOpen).toBe(false) + }) + + it("opens the upsell dialog when openUpsell is called", () => { + const { result } = renderHook(() => useCloudUpsell()) + + act(() => { + result.current.openUpsell() + }) + + expect(result.current.isOpen).toBe(true) + }) + + it("closes the upsell dialog when closeUpsell is called", () => { + const { result } = renderHook(() => useCloudUpsell()) + + // First open it + act(() => { + result.current.openUpsell() + }) + expect(result.current.isOpen).toBe(true) + + // Then close it + act(() => { + result.current.closeUpsell() + }) + expect(result.current.isOpen).toBe(false) + }) + + it("handles connect action correctly", async () => { + const { vscode } = await import("@/utils/vscode") + const { telemetryClient } = await import("@/utils/TelemetryClient") + + const { result } = renderHook(() => useCloudUpsell()) + + // Open the dialog first + act(() => { + result.current.openUpsell() + }) + expect(result.current.isOpen).toBe(true) + + // Call handleConnect + act(() => { + result.current.handleConnect() + }) + + // Check that telemetry was sent + expect(telemetryClient.capture).toHaveBeenCalledWith(TelemetryEventName.SHARE_CONNECT_TO_CLOUD_CLICKED) + + // Check that the sign-in message was posted + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "rooCloudSignIn", + }) + + // Check that the dialog was closed + expect(result.current.isOpen).toBe(false) + }) +}) diff --git a/webview-ui/src/hooks/useCloudUpsell.ts b/webview-ui/src/hooks/useCloudUpsell.ts new file mode 100644 index 0000000000..a76a898207 --- /dev/null +++ b/webview-ui/src/hooks/useCloudUpsell.ts @@ -0,0 +1,34 @@ +import { useState, useCallback } from "react" +import { TelemetryEventName } from "@roo-code/types" +import { vscode } from "@/utils/vscode" +import { telemetryClient } from "@/utils/TelemetryClient" + +export const useCloudUpsell = () => { + const [isOpen, setIsOpen] = useState(false) + + const openUpsell = useCallback(() => { + setIsOpen(true) + }, []) + + const closeUpsell = useCallback(() => { + setIsOpen(false) + }, []) + + const handleConnect = useCallback(() => { + // Send telemetry for connect to cloud action + telemetryClient.capture(TelemetryEventName.SHARE_CONNECT_TO_CLOUD_CLICKED) + + // Send message to VS Code to initiate sign in + vscode.postMessage({ type: "rooCloudSignIn" }) + + // Close the upsell dialog + closeUpsell() + }, [closeUpsell]) + + return { + isOpen, + openUpsell, + closeUpsell, + handleConnect, + } +} From 865149da093f2e0cceb12a4e2c17162bbda5179e Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 8 Sep 2025 09:30:02 -0500 Subject: [PATCH 02/32] Revert PR #7188 - Restore temperature parameter to fix TabbyApi/ExLlamaV2 crashes (#7594) --- src/api/providers/__tests__/chutes.spec.ts | 5 ++ src/api/providers/__tests__/fireworks.spec.ts | 1 + src/api/providers/__tests__/groq.spec.ts | 79 +------------------ src/api/providers/__tests__/openai.spec.ts | 67 +--------------- src/api/providers/__tests__/roo.spec.ts | 6 +- src/api/providers/__tests__/sambanova.spec.ts | 1 + src/api/providers/__tests__/zai.spec.ts | 1 + .../base-openai-compatible-provider.ts | 8 +- src/api/providers/openai.ts | 9 +-- 9 files changed, 17 insertions(+), 160 deletions(-) diff --git a/src/api/providers/__tests__/chutes.spec.ts b/src/api/providers/__tests__/chutes.spec.ts index f6fd1ef045..398f86ce60 100644 --- a/src/api/providers/__tests__/chutes.spec.ts +++ b/src/api/providers/__tests__/chutes.spec.ts @@ -409,6 +409,10 @@ describe("ChutesHandler", () => { content: `${systemPrompt}\n${messages[0].content}`, }, ], + max_tokens: 32768, + temperature: 0.6, + stream: true, + stream_options: { include_usage: true }, }), ) }) @@ -438,6 +442,7 @@ describe("ChutesHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, + temperature: 0.5, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index ed1e119a99..f07c1797a0 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -373,6 +373,7 @@ describe("FireworksHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, + temperature: 0.5, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts index 52846617f4..66bf0690a8 100644 --- a/src/api/providers/__tests__/groq.spec.ts +++ b/src/api/providers/__tests__/groq.spec.ts @@ -160,11 +160,7 @@ describe("GroqHandler", () => { it("createMessage should pass correct parameters to Groq client", async () => { const modelId: GroqModelId = "llama-3.1-8b-instant" const modelInfo = groqModels[modelId] - const handlerWithModel = new GroqHandler({ - apiModelId: modelId, - groqApiKey: "test-groq-api-key", - modelTemperature: 0.5, // Explicitly set temperature for this test - }) + const handlerWithModel = new GroqHandler({ apiModelId: modelId, groqApiKey: "test-groq-api-key" }) mockCreate.mockImplementationOnce(() => { return { @@ -194,77 +190,4 @@ describe("GroqHandler", () => { undefined, ) }) - - it("should omit temperature when modelTemperature is undefined", async () => { - const modelId: GroqModelId = "llama-3.1-8b-instant" - const handlerWithoutTemp = new GroqHandler({ - apiModelId: modelId, - groqApiKey: "test-groq-api-key", - // modelTemperature is not set - }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - const systemPrompt = "Test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] - - const messageGenerator = handlerWithoutTemp.createMessage(systemPrompt, messages) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - }), - undefined, - ) - - // Verify temperature is NOT included - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("temperature") - }) - - it("should include temperature when modelTemperature is explicitly set", async () => { - const modelId: GroqModelId = "llama-3.1-8b-instant" - const handlerWithTemp = new GroqHandler({ - apiModelId: modelId, - groqApiKey: "test-groq-api-key", - modelTemperature: 0.7, - }) - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - const systemPrompt = "Test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] - - const messageGenerator = handlerWithTemp.createMessage(systemPrompt, messages) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - temperature: 0.7, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - }), - undefined, - ) - }) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 14ed35430a..3e744d6e16 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -315,71 +315,6 @@ describe("OpenAiHandler", () => { const callArgs = mockCreate.mock.calls[0][0] expect(callArgs.max_completion_tokens).toBe(4096) }) - - it("should omit temperature when modelTemperature is undefined", async () => { - const optionsWithoutTemperature: ApiHandlerOptions = { - ...mockOptions, - // modelTemperature is not set, should not include temperature - } - const handlerWithoutTemperature = new OpenAiHandler(optionsWithoutTemperature) - const stream = handlerWithoutTemperature.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - } - // Assert the mockCreate was called without temperature - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("temperature") - }) - - it("should include temperature when modelTemperature is explicitly set to 0", async () => { - const optionsWithZeroTemperature: ApiHandlerOptions = { - ...mockOptions, - modelTemperature: 0, - } - const handlerWithZeroTemperature = new OpenAiHandler(optionsWithZeroTemperature) - const stream = handlerWithZeroTemperature.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - } - // Assert the mockCreate was called with temperature: 0 - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.temperature).toBe(0) - }) - - it("should include temperature when modelTemperature is set to a non-zero value", async () => { - const optionsWithCustomTemperature: ApiHandlerOptions = { - ...mockOptions, - modelTemperature: 0.7, - } - const handlerWithCustomTemperature = new OpenAiHandler(optionsWithCustomTemperature) - const stream = handlerWithCustomTemperature.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - } - // Assert the mockCreate was called with temperature: 0.7 - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.temperature).toBe(0.7) - }) - - it("should include DEEP_SEEK_DEFAULT_TEMPERATURE for deepseek-reasoner models when temperature is not set", async () => { - const deepseekOptions: ApiHandlerOptions = { - ...mockOptions, - openAiModelId: "deepseek-reasoner", - // modelTemperature is not set - } - const deepseekHandler = new OpenAiHandler(deepseekOptions) - const stream = deepseekHandler.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - } - // Assert the mockCreate was called with DEEP_SEEK_DEFAULT_TEMPERATURE (0.6) - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.temperature).toBe(0.6) - }) }) describe("error handling", () => { @@ -515,7 +450,7 @@ describe("OpenAiHandler", () => { ], stream: true, stream_options: { include_usage: true }, - // temperature should be omitted when not set + temperature: 0, }, { path: "/models/chat/completions" }, ) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 5509c51e02..5897156b0a 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -354,7 +354,7 @@ describe("RooHandler", () => { }) describe("temperature and model configuration", () => { - it("should omit temperature when not explicitly set", async () => { + it("should use default temperature of 0.7", async () => { handler = new RooHandler(mockOptions) const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { @@ -362,8 +362,8 @@ describe("RooHandler", () => { } expect(mockCreate).toHaveBeenCalledWith( - expect.not.objectContaining({ - temperature: expect.anything(), + expect.objectContaining({ + temperature: 0.7, }), undefined, ) diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 81de3058c8..d8cae8bf80 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -144,6 +144,7 @@ describe("SambaNovaHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, + temperature: 0.7, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 6882cfe448..a16aa9fcdf 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -220,6 +220,7 @@ describe("ZAiHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, + temperature: ZAI_DEFAULT_TEMPERATURE, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 5d2b9425e7..fb6c5d0377 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -74,19 +74,17 @@ export abstract class BaseOpenAiCompatibleProvider info: { maxTokens: max_tokens }, } = this.getModel() + const temperature = this.options.modelTemperature ?? this.defaultTemperature + const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model, max_tokens, + temperature, messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], stream: true, stream_options: { include_usage: true }, } - // Only include temperature if explicitly set - if (this.options.modelTemperature !== undefined) { - params.temperature = this.options.modelTemperature - } - try { return this.client.chat.completions.create(params, requestOptions) } catch (error) { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 2a57f25131..aebe671712 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -159,20 +159,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelId, + temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), messages: convertedMessages, stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), ...(reasoning && reasoning), } - // Only include temperature if explicitly set - if (this.options.modelTemperature !== undefined) { - requestOptions.temperature = this.options.modelTemperature - } else if (deepseekReasoner) { - // DeepSeek Reasoner has a specific default temperature - requestOptions.temperature = DEEP_SEEK_DEFAULT_TEMPERATURE - } - // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) From 965dfc08f5454539f9119876b790861210ec5895 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:46:59 -0500 Subject: [PATCH 03/32] fix: reduce CodeBlock button z-index to prevent overlap with popovers (#7783) Fixes #7703 - CodeBlock language dropdown and copy button were appearing above popovers due to z-index: 100. Reduced to z-index: 40 to maintain proper layering hierarchy while keeping buttons functional. --- webview-ui/src/components/common/CodeBlock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index 28492acd8b..ef415e342c 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -74,7 +74,7 @@ const CodeBlockButtonWrapper = styled.div` top: var(--copy-button-top); right: var(--copy-button-right, 8px); height: auto; - z-index: 100; + z-index: 40; background: ${CODE_BLOCK_BG_COLOR}${WRAPPER_ALPHA}; overflow: visible; pointer-events: none; From 8ba47d10cab549205f246b2785d4817d188c790a Mon Sep 17 00:00:00 2001 From: ItsOnlyBinary Date: Tue, 9 Sep 2025 03:48:22 +0100 Subject: [PATCH 04/32] Make ollama models info transport work like lmstudio (#7679) --- .../__tests__/webviewMessageHandler.spec.ts | 42 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 4 +- src/shared/ExtensionMessage.ts | 2 +- .../components/settings/providers/Ollama.tsx | 15 ++++--- .../components/ui/hooks/useOllamaModels.ts | 39 +++++++++++++++++ .../components/ui/hooks/useSelectedModel.ts | 9 +++- 6 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 webview-ui/src/components/ui/hooks/useOllamaModels.ts diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 429dc87856..12d7615124 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -136,6 +136,48 @@ describe("webviewMessageHandler - requestLmStudioModels", () => { }) }) +describe("webviewMessageHandler - requestOllamaModels", () => { + beforeEach(() => { + vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { + ollamaModelId: "model-1", + ollamaBaseUrl: "http://localhost:1234", + }, + }) + }) + + it("successfully fetches models from Ollama", async () => { + const mockModels: ModelRecord = { + "model-1": { + maxTokens: 4096, + contextWindow: 8192, + supportsPromptCache: false, + description: "Test model 1", + }, + "model-2": { + maxTokens: 8192, + contextWindow: 16384, + supportsPromptCache: false, + description: "Test model 2", + }, + } + + mockGetModels.mockResolvedValue(mockModels) + + await webviewMessageHandler(mockClineProvider, { + type: "requestOllamaModels", + }) + + expect(mockGetModels).toHaveBeenCalledWith({ provider: "ollama", baseUrl: "http://localhost:1234" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "ollamaModels", + ollamaModels: mockModels, + }) + }) +}) + describe("webviewMessageHandler - requestRouterModels", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 080fbbcd94..6ad1864064 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -797,7 +797,7 @@ export const webviewMessageHandler = async ( if (routerName === "ollama" && Object.keys(result.value.models).length > 0) { provider.postMessageToWebview({ type: "ollamaModels", - ollamaModels: Object.keys(result.value.models), + ollamaModels: result.value.models, }) } else if (routerName === "lmstudio" && Object.keys(result.value.models).length > 0) { provider.postMessageToWebview({ @@ -842,7 +842,7 @@ export const webviewMessageHandler = async ( if (Object.keys(ollamaModels).length > 0) { provider.postMessageToWebview({ type: "ollamaModels", - ollamaModels: Object.keys(ollamaModels), + ollamaModels: ollamaModels, }) } } catch (error) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index d08c66e36b..1565bb8c52 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -148,7 +148,7 @@ export interface ExtensionMessage { clineMessage?: ClineMessage routerModels?: RouterModels openAiModels?: string[] - ollamaModels?: string[] + ollamaModels?: ModelRecord lmStudioModels?: ModelRecord vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[] huggingFaceModels?: Array<{ diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx index b09ecad5d6..b3ff00ccdd 100644 --- a/webview-ui/src/components/settings/providers/Ollama.tsx +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -11,6 +11,7 @@ import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" import { vscode } from "@src/utils/vscode" import { inputEventTransform } from "../transforms" +import { ModelRecord } from "@roo/api" type OllamaProps = { apiConfiguration: ProviderSettings @@ -20,7 +21,7 @@ type OllamaProps = { export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaProps) => { const { t } = useAppTranslation() - const [ollamaModels, setOllamaModels] = useState([]) + const [ollamaModels, setOllamaModels] = useState({}) const routerModels = useRouterModels() const handleInputChange = useCallback( @@ -40,7 +41,7 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro switch (message.type) { case "ollamaModels": { - const newModels = message.ollamaModels ?? [] + const newModels = message.ollamaModels ?? {} setOllamaModels(newModels) } break @@ -61,7 +62,7 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro if (!selectedModel) return false // Check if model exists in local ollama models - if (ollamaModels.length > 0 && ollamaModels.includes(selectedModel)) { + if (Object.keys(ollamaModels).length > 0 && selectedModel in ollamaModels) { return false // Model is available locally } @@ -116,15 +117,13 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro )} - {ollamaModels.length > 0 && ( + {Object.keys(ollamaModels).length > 0 && ( - {ollamaModels.map((model) => ( + {Object.keys(ollamaModels).map((model) => ( {model} diff --git a/webview-ui/src/components/ui/hooks/useOllamaModels.ts b/webview-ui/src/components/ui/hooks/useOllamaModels.ts new file mode 100644 index 0000000000..67a172b0d8 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/useOllamaModels.ts @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query" + +import { ModelRecord } from "@roo/api" +import { ExtensionMessage } from "@roo/ExtensionMessage" + +import { vscode } from "@src/utils/vscode" + +const getOllamaModels = async () => + new Promise((resolve, reject) => { + const cleanup = () => { + window.removeEventListener("message", handler) + } + + const timeout = setTimeout(() => { + cleanup() + reject(new Error("Ollama models request timed out")) + }, 10000) + + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + + if (message.type === "ollamaModels") { + clearTimeout(timeout) + cleanup() + + if (message.ollamaModels) { + resolve(message.ollamaModels) + } else { + reject(new Error("No Ollama models in response")) + } + } + } + + window.addEventListener("message", handler) + vscode.postMessage({ type: "requestOllamaModels" }) + }) + +export const useOllamaModels = (modelId?: string) => + useQuery({ queryKey: ["ollamaModels"], queryFn: () => (modelId ? getOllamaModels() : {}) }) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index b7fe4ff03d..f8a005e86a 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -64,19 +64,23 @@ import type { ModelRecord, RouterModels } from "@roo/api" import { useRouterModels } from "./useRouterModels" import { useOpenRouterModelProviders } from "./useOpenRouterModelProviders" import { useLmStudioModels } from "./useLmStudioModels" +import { useOllamaModels } from "./useOllamaModels" export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const provider = apiConfiguration?.apiProvider || "anthropic" const openRouterModelId = provider === "openrouter" ? apiConfiguration?.openRouterModelId : undefined const lmStudioModelId = provider === "lmstudio" ? apiConfiguration?.lmStudioModelId : undefined + const ollamaModelId = provider === "ollama" ? apiConfiguration?.ollamaModelId : undefined const routerModels = useRouterModels() const openRouterModelProviders = useOpenRouterModelProviders(openRouterModelId) const lmStudioModels = useLmStudioModels(lmStudioModelId) + const ollamaModels = useOllamaModels(ollamaModelId) const { id, info } = apiConfiguration && (typeof lmStudioModelId === "undefined" || typeof lmStudioModels.data !== "undefined") && + (typeof ollamaModelId === "undefined" || typeof ollamaModels.data !== "undefined") && typeof routerModels.data !== "undefined" && typeof openRouterModelProviders.data !== "undefined" ? getSelectedModel({ @@ -85,6 +89,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { routerModels: routerModels.data, openRouterModelProviders: openRouterModelProviders.data, lmStudioModels: lmStudioModels.data, + ollamaModels: ollamaModels.data, }) : { id: anthropicDefaultModelId, info: undefined } @@ -109,12 +114,14 @@ function getSelectedModel({ routerModels, openRouterModelProviders, lmStudioModels, + ollamaModels, }: { provider: ProviderName apiConfiguration: ProviderSettings routerModels: RouterModels openRouterModelProviders: Record lmStudioModels: ModelRecord | undefined + ollamaModels: ModelRecord | undefined }): { id: string; info: ModelInfo | undefined } { // the `undefined` case are used to show the invalid selection to prevent // users from seeing the default model if their selection is invalid @@ -255,7 +262,7 @@ function getSelectedModel({ } case "ollama": { const id = apiConfiguration.ollamaModelId ?? "" - const info = routerModels.ollama && routerModels.ollama[id] + const info = ollamaModels && ollamaModels[apiConfiguration.ollamaModelId!] return { id, info: info || undefined, From c0c921ca38d6f0a4dd4d391e0d5789945e3d813e Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 22:50:21 -0400 Subject: [PATCH 05/32] feat: add click-to-edit, ESC-to-cancel, and fix padding consistency for chat messages (#7790) * feat: add click-to-edit, ESC-to-cancel, and fix padding consistency - Enable click-to-edit for past messages by making message text clickable - Add ESC key handler to cancel edit mode in ChatTextArea - Fix padding consistency between past and queued message editors - Adjust right padding for edit mode to accommodate cancel button Fixes #7788 * fix: adjust padding and layout for ChatTextArea in edit mode * refactor: replace hardcoded pr-[72px] with standard Tailwind pr-20 class --------- Co-authored-by: Roo Code Co-authored-by: Hannes Rudolph Co-authored-by: daniel-lxs --- webview-ui/src/components/chat/ChatRow.tsx | 15 ++++-- .../src/components/chat/ChatTextArea.tsx | 48 +++++++++---------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 7b3107a2be..f24e2ca660 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1172,9 +1172,10 @@ export const ChatRowContent = ({ ) case "user_feedback": return ( -
+
{isEditing ? ( -
+
) : (
-
+
{ + e.stopPropagation() + if (!isStreaming) { + handleEditClick() + } + }} + title={t("chat:queuedMessages.clickToEdit")}>
diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c917797283..e7a9f67b8b 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -903,20 +903,8 @@ export const ChatTextArea = forwardRef( return (
( : isDraggingOver ? "border-2 border-dashed border-vscode-focusBorder" : "border border-transparent", - "px-[8px]", - "py-1.5", - "pr-9", + "pl-2", + "py-2", + isEditMode ? "pr-20" : "pr-9", "z-10", "forced-color-adjust-none", + "rounded", )} style={{ color: "transparent", @@ -1030,7 +1019,15 @@ export const ChatTextArea = forwardRef( updateHighlights() }} onFocus={() => setIsFocused(true)} - onKeyDown={handleKeyDown} + onKeyDown={(e) => { + // Handle ESC to cancel in edit mode + if (isEditMode && e.key === "Escape" && !e.nativeEvent?.isComposing) { + e.preventDefault() + onCancel?.() + return + } + handleKeyDown(e) + }} onKeyUp={handleKeyUp} onBlur={handleBlur} onPaste={handlePaste} @@ -1054,7 +1051,7 @@ export const ChatTextArea = forwardRef( "text-vscode-editor-font-size", "leading-vscode-editor-line-height", "cursor-text", - "py-1.5 px-2", + "py-2 pl-2", isFocused ? "border border-vscode-focusBorder outline outline-vscode-focusBorder" : isDraggingOver @@ -1071,7 +1068,7 @@ export const ChatTextArea = forwardRef( "resize-none", "overflow-x-hidden", "overflow-y-auto", - "pr-9", + isEditMode ? "pr-20" : "pr-9", "flex-none flex-grow", "z-[2]", "scrollbar-none", @@ -1080,7 +1077,7 @@ export const ChatTextArea = forwardRef( onScroll={() => updateHighlights()} /> -
+
-
+
{isEditMode && ( + )} +
+ )} + + {showManualEntry && ( + // Manual URL entry form +
+

+ {t("cloud:pasteCallbackUrl")} +

+ + +
+ )}
)} diff --git a/webview-ui/src/i18n/locales/ca/cloud.json b/webview-ui/src/i18n/locales/ca/cloud.json index 02714c7c4b..94c12d75e1 100644 --- a/webview-ui/src/i18n/locales/ca/cloud.json +++ b/webview-ui/src/i18n/locales/ca/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Permet seguir i interactuar amb tasques en aquest espai de treball amb Roo Code Cloud", "visitCloudWebsite": "Visita Roo Code Cloud", - "cloudUrlPillLabel": "URL de Roo Code Cloud" + "cloudUrlPillLabel": "URL de Roo Code Cloud", + "authWaiting": "Esperant que es completi l'autenticació...", + "havingTrouble": "Tens problemes?", + "pasteCallbackUrl": "Copia l'URL de redirect del teu navegador i enganxa-la aquí:", + "startOver": "Torna a començar" } diff --git a/webview-ui/src/i18n/locales/de/cloud.json b/webview-ui/src/i18n/locales/de/cloud.json index cbba345399..e76245f430 100644 --- a/webview-ui/src/i18n/locales/de/cloud.json +++ b/webview-ui/src/i18n/locales/de/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Ermöglicht das Verfolgen und Interagieren mit Aufgaben in diesem Arbeitsbereich mit Roo Code Cloud", "visitCloudWebsite": "Roo Code Cloud besuchen", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "Warte auf Abschluss der Authentifizierung...", + "havingTrouble": "Probleme?", + "pasteCallbackUrl": "Kopiere die Redirect-URL aus deinem Browser und füge sie hier ein:", + "startOver": "Von vorne beginnen" } diff --git a/webview-ui/src/i18n/locales/en/cloud.json b/webview-ui/src/i18n/locales/en/cloud.json index 88948c9153..c436f50bc4 100644 --- a/webview-ui/src/i18n/locales/en/cloud.json +++ b/webview-ui/src/i18n/locales/en/cloud.json @@ -13,5 +13,9 @@ "visitCloudWebsite": "Visit Roo Code Cloud", "remoteControl": "Roomote Control", "remoteControlDescription": "Enable following and interacting with tasks in this workspace with Roo Code Cloud", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "Waiting for browser authentication...", + "havingTrouble": "Having trouble?", + "pasteCallbackUrl": "Copy the redirect URL from your browser and paste it here:", + "startOver": "Start over" } diff --git a/webview-ui/src/i18n/locales/es/cloud.json b/webview-ui/src/i18n/locales/es/cloud.json index 2497edf7bf..1515a2a917 100644 --- a/webview-ui/src/i18n/locales/es/cloud.json +++ b/webview-ui/src/i18n/locales/es/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Permite seguir e interactuar con tareas en este espacio de trabajo con Roo Code Cloud", "visitCloudWebsite": "Visitar Roo Code Cloud", - "cloudUrlPillLabel": "URL de Roo Code Cloud" + "cloudUrlPillLabel": "URL de Roo Code Cloud", + "authWaiting": "Esperando que se complete la autenticación...", + "havingTrouble": "¿Tienes problemas?", + "pasteCallbackUrl": "Copia la URL de redirect desde tu navegador y pégala aquí:", + "startOver": "Empezar de nuevo" } diff --git a/webview-ui/src/i18n/locales/fr/cloud.json b/webview-ui/src/i18n/locales/fr/cloud.json index 76db922933..5ed35af6a7 100644 --- a/webview-ui/src/i18n/locales/fr/cloud.json +++ b/webview-ui/src/i18n/locales/fr/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Permet de suivre et d'interagir avec les tâches dans cet espace de travail avec Roo Code Cloud", "visitCloudWebsite": "Visiter Roo Code Cloud", - "cloudUrlPillLabel": "URL de Roo Code Cloud" + "cloudUrlPillLabel": "URL de Roo Code Cloud", + "authWaiting": "En attente de la fin de l'authentification...", + "havingTrouble": "Des difficultés ?", + "pasteCallbackUrl": "Copie l'URL de redirect depuis ton navigateur et colle-la ici :", + "startOver": "Recommencer" } diff --git a/webview-ui/src/i18n/locales/hi/cloud.json b/webview-ui/src/i18n/locales/hi/cloud.json index 60d7103c25..c10e7de35c 100644 --- a/webview-ui/src/i18n/locales/hi/cloud.json +++ b/webview-ui/src/i18n/locales/hi/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Roo Code Cloud के साथ इस वर्कस्पेस में कार्यों को फॉलो और इंटरैक्ट करने की सुविधा दें", "visitCloudWebsite": "Roo Code Cloud पर जाएं", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "प्रमाणीकरण पूरा होने की प्रतीक्षा कर रहे हैं...", + "havingTrouble": "समस्या हो रही है?", + "pasteCallbackUrl": "अपने ब्राउज़र से redirect URL कॉपी करें और यहाँ पेस्ट करें:", + "startOver": "फिर से शुरू करें" } diff --git a/webview-ui/src/i18n/locales/id/cloud.json b/webview-ui/src/i18n/locales/id/cloud.json index e48bb16fe8..27ea5c4bb7 100644 --- a/webview-ui/src/i18n/locales/id/cloud.json +++ b/webview-ui/src/i18n/locales/id/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Memungkinkan mengikuti dan berinteraksi dengan tugas di workspace ini dengan Roo Code Cloud", "visitCloudWebsite": "Kunjungi Roo Code Cloud", - "cloudUrlPillLabel": "URL Roo Code Cloud" + "cloudUrlPillLabel": "URL Roo Code Cloud", + "authWaiting": "Menunggu autentikasi selesai...", + "havingTrouble": "Ada masalah?", + "pasteCallbackUrl": "Salin URL redirect dari browser dan tempel di sini:", + "startOver": "Mulai dari awal" } diff --git a/webview-ui/src/i18n/locales/it/cloud.json b/webview-ui/src/i18n/locales/it/cloud.json index 0678fcd721..74bd07144c 100644 --- a/webview-ui/src/i18n/locales/it/cloud.json +++ b/webview-ui/src/i18n/locales/it/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Abilita il monitoraggio e l'interazione con le attività in questo workspace con Roo Code Cloud", "visitCloudWebsite": "Visita Roo Code Cloud", - "cloudUrlPillLabel": "URL di Roo Code Cloud" + "cloudUrlPillLabel": "URL di Roo Code Cloud", + "authWaiting": "In attesa del completamento dell'autenticazione...", + "havingTrouble": "Hai problemi?", + "pasteCallbackUrl": "Copia l'URL di redirect dal tuo browser e incollalo qui:", + "startOver": "Ricomincia" } diff --git a/webview-ui/src/i18n/locales/ja/cloud.json b/webview-ui/src/i18n/locales/ja/cloud.json index 4b409af9e0..1ab89d0435 100644 --- a/webview-ui/src/i18n/locales/ja/cloud.json +++ b/webview-ui/src/i18n/locales/ja/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Roo Code Cloudでこのワークスペースのタスクをフォローし操作することを有効にする", "visitCloudWebsite": "Roo Code Cloudを訪問", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "認証完了をお待ちください...", + "havingTrouble": "問題が発生していますか?", + "pasteCallbackUrl": "ブラウザからリダイレクトURLをコピーし、ここに貼り付けてください:", + "startOver": "最初からやり直す" } diff --git a/webview-ui/src/i18n/locales/ko/cloud.json b/webview-ui/src/i18n/locales/ko/cloud.json index 4272a94acf..96b4760ee3 100644 --- a/webview-ui/src/i18n/locales/ko/cloud.json +++ b/webview-ui/src/i18n/locales/ko/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Roo Code Cloud로 이 워크스페이스의 작업을 팔로우하고 상호작용할 수 있게 합니다", "visitCloudWebsite": "Roo Code Cloud 방문", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "인증 완료를 기다리는 중...", + "havingTrouble": "문제가 있나요?", + "pasteCallbackUrl": "브라우저에서 리다이렉트 URL을 복사하여 여기에 붙여넣으세요:", + "startOver": "다시 시작" } diff --git a/webview-ui/src/i18n/locales/nl/cloud.json b/webview-ui/src/i18n/locales/nl/cloud.json index f77a37fbf0..5c2651d44a 100644 --- a/webview-ui/src/i18n/locales/nl/cloud.json +++ b/webview-ui/src/i18n/locales/nl/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Schakel het volgen en interacteren met taken in deze workspace in met Roo Code Cloud", "visitCloudWebsite": "Bezoek Roo Code Cloud", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "Wachten tot authenticatie voltooid is...", + "havingTrouble": "Problemen?", + "pasteCallbackUrl": "Kopieer de redirect-URL uit je browser en plak hem hier:", + "startOver": "Opnieuw beginnen" } diff --git a/webview-ui/src/i18n/locales/pl/cloud.json b/webview-ui/src/i18n/locales/pl/cloud.json index 4f98bf0b98..0cb860ee1f 100644 --- a/webview-ui/src/i18n/locales/pl/cloud.json +++ b/webview-ui/src/i18n/locales/pl/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Umożliwia śledzenie i interakcję z zadaniami w tym obszarze roboczym za pomocą Roo Code Cloud", "visitCloudWebsite": "Odwiedź Roo Code Cloud", - "cloudUrlPillLabel": "URL Roo Code Cloud" + "cloudUrlPillLabel": "URL Roo Code Cloud", + "authWaiting": "Oczekiwanie na zakończenie uwierzytelniania...", + "havingTrouble": "Masz problemy?", + "pasteCallbackUrl": "Skopiuj URL redirect z przeglądarki i wklej tutaj:", + "startOver": "Zacznij od nowa" } diff --git a/webview-ui/src/i18n/locales/pt-BR/cloud.json b/webview-ui/src/i18n/locales/pt-BR/cloud.json index 749395edae..7e0a29e38b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/cloud.json +++ b/webview-ui/src/i18n/locales/pt-BR/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Permite acompanhar e interagir com tarefas neste workspace com Roo Code Cloud", "visitCloudWebsite": "Visitar Roo Code Cloud", - "cloudUrlPillLabel": "URL do Roo Code Cloud " + "cloudUrlPillLabel": "URL do Roo Code Cloud ", + "authWaiting": "Aguardando conclusão da autenticação...", + "havingTrouble": "Tendo problemas?", + "pasteCallbackUrl": "Copie a URL de redirect do seu navegador e cole aqui:", + "startOver": "Recomeçar" } diff --git a/webview-ui/src/i18n/locales/ru/cloud.json b/webview-ui/src/i18n/locales/ru/cloud.json index 5fd6dc372b..b30d6064a2 100644 --- a/webview-ui/src/i18n/locales/ru/cloud.json +++ b/webview-ui/src/i18n/locales/ru/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Позволяет отслеживать и взаимодействовать с задачами в этом рабочем пространстве с Roo Code Cloud", "visitCloudWebsite": "Посетить Roo Code Cloud", - "cloudUrlPillLabel": "URL Roo Code Cloud" + "cloudUrlPillLabel": "URL Roo Code Cloud", + "authWaiting": "Ожидание завершения аутентификации...", + "havingTrouble": "Проблемы?", + "pasteCallbackUrl": "Скопируй URL перенаправления из браузера и вставь его сюда:", + "startOver": "Начать заново" } diff --git a/webview-ui/src/i18n/locales/tr/cloud.json b/webview-ui/src/i18n/locales/tr/cloud.json index 822e837a9f..c82661163f 100644 --- a/webview-ui/src/i18n/locales/tr/cloud.json +++ b/webview-ui/src/i18n/locales/tr/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Bu çalışma alanındaki görevleri Roo Code Cloud ile takip etme ve etkileşim kurma imkanı sağlar", "visitCloudWebsite": "Roo Code Cloud'u ziyaret et", - "cloudUrlPillLabel": "Roo Code Cloud URL'si" + "cloudUrlPillLabel": "Roo Code Cloud URL'si", + "authWaiting": "Kimlik doğrulama tamamlanması bekleniyor...", + "havingTrouble": "Sorun yaşıyor musun?", + "pasteCallbackUrl": "Tarayıcından redirect URL'sini kopyala ve buraya yapıştır:", + "startOver": "Baştan başla" } diff --git a/webview-ui/src/i18n/locales/vi/cloud.json b/webview-ui/src/i18n/locales/vi/cloud.json index ef444e70bd..ee5c2683b0 100644 --- a/webview-ui/src/i18n/locales/vi/cloud.json +++ b/webview-ui/src/i18n/locales/vi/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "Cho phép theo dõi và tương tác với các tác vụ trong workspace này với Roo Code Cloud", "visitCloudWebsite": "Truy cập Roo Code Cloud", - "cloudUrlPillLabel": "URL Roo Code Cloud" + "cloudUrlPillLabel": "URL Roo Code Cloud", + "authWaiting": "Đang chờ hoàn tất xác thực...", + "havingTrouble": "Gặp vấn đề?", + "pasteCallbackUrl": "Sao chép URL redirect từ trình duyệt và dán vào đây:", + "startOver": "Bắt đầu lại" } diff --git a/webview-ui/src/i18n/locales/zh-CN/cloud.json b/webview-ui/src/i18n/locales/zh-CN/cloud.json index 5a90cb8ccd..c42647a096 100644 --- a/webview-ui/src/i18n/locales/zh-CN/cloud.json +++ b/webview-ui/src/i18n/locales/zh-CN/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "允许通过 Roo Code Cloud 跟踪和操作此工作区中的任务", "visitCloudWebsite": "访问 Roo Code Cloud", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "等待身份验证完成...", + "havingTrouble": "遇到问题?", + "pasteCallbackUrl": "从浏览器复制重定向 URL 并粘贴到这里:", + "startOver": "重新开始" } diff --git a/webview-ui/src/i18n/locales/zh-TW/cloud.json b/webview-ui/src/i18n/locales/zh-TW/cloud.json index 034c15e204..efb2b25ad9 100644 --- a/webview-ui/src/i18n/locales/zh-TW/cloud.json +++ b/webview-ui/src/i18n/locales/zh-TW/cloud.json @@ -14,5 +14,9 @@ "remoteControl": "Roomote Control", "remoteControlDescription": "允許透過 Roo Code Cloud 追蹤和操作此工作區中的工作", "visitCloudWebsite": "造訪 Roo Code Cloud", - "cloudUrlPillLabel": "Roo Code Cloud URL" + "cloudUrlPillLabel": "Roo Code Cloud URL", + "authWaiting": "等待身份驗證完成...", + "havingTrouble": "遇到問題?", + "pasteCallbackUrl": "從瀏覽器複製重新導向 URL 並貼上到這裡:", + "startOver": "重新開始" } From 06bdcef4dc5ae38e6a70b0d16480c854bcebb077 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:26:44 -0500 Subject: [PATCH 07/32] fix: resolve chat message edit/delete duplication issues (#7793) --- .../webview/__tests__/ClineProvider.spec.ts | 56 +-- .../webviewMessageHandler.delete.spec.ts | 245 +++++++++++ .../webviewMessageHandler.edit.spec.ts | 390 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 135 ++++-- src/i18n/locales/ca/common.json | 9 + src/i18n/locales/de/common.json | 9 + src/i18n/locales/en/common.json | 9 + src/i18n/locales/es/common.json | 9 + src/i18n/locales/fr/common.json | 9 + src/i18n/locales/hi/common.json | 9 + src/i18n/locales/id/common.json | 9 + src/i18n/locales/it/common.json | 9 + src/i18n/locales/ja/common.json | 9 + src/i18n/locales/ko/common.json | 9 + src/i18n/locales/nl/common.json | 9 + src/i18n/locales/pl/common.json | 9 + src/i18n/locales/pt-BR/common.json | 9 + src/i18n/locales/ru/common.json | 9 + src/i18n/locales/tr/common.json | 9 + src/i18n/locales/vi/common.json | 9 + src/i18n/locales/zh-CN/common.json | 9 + src/i18n/locales/zh-TW/common.json | 9 + 22 files changed, 917 insertions(+), 71 deletions(-) create mode 100644 src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts create mode 100644 src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 375de1cd89..cd41ce09ce 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1332,19 +1332,11 @@ describe("ClineProvider", () => { text: "Edited message content", }) - // Verify correct messages were kept (only messages before the edited one) - expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([ - mockMessages[0], - mockMessages[1], - mockMessages[2], - ]) + // Verify correct messages were kept - delete from the preceding user message to truly replace it + expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([]) - // Verify correct API messages were kept (only messages before the edited one) - expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([ - mockApiHistory[0], - mockApiHistory[1], - mockApiHistory[2], - ]) + // Verify correct API messages were kept + expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([]) // The new flow calls webviewMessageHandler recursively with askResponse // We need to verify the recursive call happened by checking if the handler was called again @@ -3016,7 +3008,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }] as any[] mockCline.overwriteClineMessages = vi.fn() mockCline.overwriteApiConversationHistory = vi.fn() - mockCline.handleWebviewAskResponse = vi.fn() + mockCline.submitUserMessage = vi.fn() await provider.addClineToStack(mockCline) ;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({ @@ -3046,9 +3038,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { text: "Edited message with preserved images", }) - // Verify messages were edited correctly - messages up to the edited message should remain - expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0], mockMessages[1]]) - expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }, { ts: 2000 }]) + // Verify messages were edited correctly - the ORIGINAL user message and all subsequent messages are removed + expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]]) + expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }]) + // Verify submitUserMessage was called with the edited content + expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", undefined) }) test("handles editing messages with file attachments", async () => { @@ -3070,7 +3064,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }] as any[] mockCline.overwriteClineMessages = vi.fn() mockCline.overwriteApiConversationHistory = vi.fn() - mockCline.handleWebviewAskResponse = vi.fn() + mockCline.submitUserMessage = vi.fn() await provider.addClineToStack(mockCline) ;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({ @@ -3101,11 +3095,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) expect(mockCline.overwriteClineMessages).toHaveBeenCalled() - expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith( - "messageResponse", - "Edited message with file attachment", - undefined, - ) + expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", undefined) }) }) @@ -3197,7 +3187,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: "Edited message" }) // The error should be caught and shown - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Connection lost") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_editing_message") }) }) @@ -3320,7 +3310,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { text: "Edited message", }) - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Unauthorized") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_editing_message") }) describe("Malformed Requests and Invalid Formats", () => { @@ -3544,7 +3534,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // Verify cleanup was attempted before failure expect(cleanupSpy).toHaveBeenCalled() - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Operation failed") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_editing_message") }) test("validates proper cleanup during failed delete operations", async () => { @@ -3584,9 +3574,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // Verify cleanup was attempted before failure expect(cleanupSpy).toHaveBeenCalled() - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - "Error deleting message: Delete operation failed", - ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_deleting_message") }) }) @@ -3609,7 +3597,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }] as any[] mockCline.overwriteClineMessages = vi.fn() mockCline.overwriteApiConversationHistory = vi.fn() - mockCline.handleWebviewAskResponse = vi.fn() + mockCline.submitUserMessage = vi.fn() await provider.addClineToStack(mockCline) ;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({ @@ -3638,11 +3626,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: largeEditedContent }) expect(mockCline.overwriteClineMessages).toHaveBeenCalled() - expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith( - "messageResponse", - largeEditedContent, - undefined, - ) + expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, undefined) }) test("handles deleting messages with large payloads", async () => { @@ -3822,7 +3806,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { ] as any[] mockCline.overwriteClineMessages = vi.fn() mockCline.overwriteApiConversationHistory = vi.fn() - mockCline.handleWebviewAskResponse = vi.fn() + mockCline.submitUserMessage = vi.fn() await provider.addClineToStack(mockCline) ;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({ @@ -3855,7 +3839,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // Should handle future timestamps correctly expect(mockCline.overwriteClineMessages).toHaveBeenCalled() - expect(mockCline.handleWebviewAskResponse).toHaveBeenCalled() + expect(mockCline.submitUserMessage).toHaveBeenCalled() }) }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts new file mode 100644 index 0000000000..28f6ba9cf8 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { webviewMessageHandler } from "../webviewMessageHandler" +import * as vscode from "vscode" +import { ClineProvider } from "../ClineProvider" + +// Mock the saveTaskMessages function +vi.mock("../../task-persistence", () => ({ + saveTaskMessages: vi.fn(), +})) + +// Mock the i18n module +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), + changeLanguage: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + workspace: { + workspaceFolders: undefined, + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + update: vi.fn(), + })), + }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, + Uri: { + parse: vi.fn((str) => ({ toString: () => str })), + file: vi.fn((path) => ({ fsPath: path })), + }, + env: { + openExternal: vi.fn(), + clipboard: { + writeText: vi.fn(), + }, + }, + commands: { + executeCommand: vi.fn(), + }, +})) + +describe("webviewMessageHandler delete functionality", () => { + let provider: any + let getCurrentTaskMock: any + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + + // Create mock task + getCurrentTaskMock = { + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(async () => {}), + overwriteApiConversationHistory: vi.fn(async () => {}), + taskId: "test-task-id", + } + + // Create mock provider + provider = { + getCurrentTask: vi.fn(() => getCurrentTaskMock), + postMessageToWebview: vi.fn(), + contextProxy: { + getValue: vi.fn(), + setValue: vi.fn(async () => {}), + globalStorageUri: { fsPath: "/test/path" }, + }, + log: vi.fn(), + cwd: "/test/cwd", + } + }) + + describe("handleDeleteMessageConfirm", () => { + it("should handle deletion when apiConversationHistoryIndex is -1 (message not in API history)", async () => { + // Setup test data with a user message and assistant response + const userMessageTs = 1000 + const assistantMessageTs = 1001 + + getCurrentTaskMock.clineMessages = [ + { ts: userMessageTs, say: "user", text: "Hello" }, + { ts: assistantMessageTs, say: "assistant", text: "Hi there" }, + ] + + // API history has the assistant message but not the user message + // This simulates the case where the user message wasn't in API history + getCurrentTaskMock.apiConversationHistory = [ + { ts: assistantMessageTs, role: "assistant", content: { type: "text", text: "Hi there" } }, + { + ts: 1002, + role: "assistant", + content: { type: "text", text: "attempt_completion" }, + name: "attempt_completion", + }, + ] + + // Call delete for the user message + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: userMessageTs, + }) + + // Verify that clineMessages was truncated at the correct index + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([]) + + // When message is not found in API history (index is -1), + // API history should be truncated from the first API message at/after the deleted timestamp (fallback) + expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([]) + }) + + it("should handle deletion when exact apiConversationHistoryIndex is found", async () => { + // Setup test data where message exists in both arrays + const messageTs = 1000 + + getCurrentTaskMock.clineMessages = [ + { ts: 900, say: "user", text: "Previous message" }, + { ts: messageTs, say: "user", text: "Delete this" }, + { ts: 1100, say: "assistant", text: "Response" }, + ] + + getCurrentTaskMock.apiConversationHistory = [ + { ts: 900, role: "user", content: { type: "text", text: "Previous message" } }, + { ts: messageTs, role: "user", content: { type: "text", text: "Delete this" } }, + { ts: 1100, role: "assistant", content: { type: "text", text: "Response" } }, + ] + + // Call delete + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: messageTs, + }) + + // Verify truncation at correct indices + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([ + { ts: 900, say: "user", text: "Previous message" }, + ]) + + expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([ + { ts: 900, role: "user", content: { type: "text", text: "Previous message" } }, + ]) + }) + + it("should handle deletion when message not found in clineMessages", async () => { + getCurrentTaskMock.clineMessages = [{ ts: 1000, say: "user", text: "Some message" }] + + getCurrentTaskMock.apiConversationHistory = [] + + // Call delete with non-existent timestamp + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: 9999, + }) + + // Verify error message was shown (expecting translation key since t() is mocked to return the key) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.message.message_not_found") + + // Verify no truncation occurred + expect(getCurrentTaskMock.overwriteClineMessages).not.toHaveBeenCalled() + expect(getCurrentTaskMock.overwriteApiConversationHistory).not.toHaveBeenCalled() + }) + + it("should handle deletion with attempt_completion in API history", async () => { + // Setup test data with attempt_completion + const userMessageTs = 1000 + const attemptCompletionTs = 1001 + + getCurrentTaskMock.clineMessages = [ + { ts: userMessageTs, say: "user", text: "Fix the bug" }, + { ts: attemptCompletionTs, say: "assistant", text: "I've fixed the bug" }, + ] + + // API history has attempt_completion but user message is missing + getCurrentTaskMock.apiConversationHistory = [ + { + ts: attemptCompletionTs, + role: "assistant", + content: { + type: "text", + text: "I've fixed the bug in the code", + }, + name: "attempt_completion", + }, + { + ts: 1002, + role: "user", + content: { type: "text", text: "Looks good, but..." }, + }, + ] + + // Call delete for the user message + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: userMessageTs, + }) + + // Verify that clineMessages was truncated + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([]) + + // API history should be truncated from first message at/after deleted timestamp (fallback) + expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([]) + }) + + it("should preserve messages before the deleted one", async () => { + const messageTs = 2000 + + getCurrentTaskMock.clineMessages = [ + { ts: 1000, say: "user", text: "First message" }, + { ts: 1500, say: "assistant", text: "First response" }, + { ts: messageTs, say: "user", text: "Delete this" }, + { ts: 2500, say: "assistant", text: "Response to delete" }, + ] + + getCurrentTaskMock.apiConversationHistory = [ + { ts: 1000, role: "user", content: { type: "text", text: "First message" } }, + { ts: 1500, role: "assistant", content: { type: "text", text: "First response" } }, + { ts: messageTs, role: "user", content: { type: "text", text: "Delete this" } }, + { ts: 2500, role: "assistant", content: { type: "text", text: "Response to delete" } }, + ] + + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: messageTs, + }) + + // Should preserve messages before the deleted one + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([ + { ts: 1000, say: "user", text: "First message" }, + { ts: 1500, say: "assistant", text: "First response" }, + ]) + + // API history should be truncated at the exact index + expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([ + { ts: 1000, role: "user", content: { type: "text", text: "First message" } }, + { ts: 1500, role: "assistant", content: { type: "text", text: "First response" } }, + ]) + }) + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts new file mode 100644 index 0000000000..d467f5cd92 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -0,0 +1,390 @@ +import type { Mock } from "vitest" +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Mock dependencies first +vi.mock("vscode", () => ({ + window: { + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn(), + update: vi.fn(), + }), + }, + Uri: { + file: vi.fn((path) => ({ fsPath: path })), + }, + env: { + uriScheme: "vscode", + }, +})) + +vi.mock("../../task-persistence", () => ({ + saveTaskMessages: vi.fn(), +})) + +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn(), + flushModels: vi.fn(), +})) + +vi.mock("../checkpointRestoreHandler", () => ({ + handleCheckpointRestoreOperation: vi.fn(), +})) + +// Import after mocks +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" +import type { ClineMessage } from "@roo-code/types" +import type { ApiMessage } from "../../task-persistence/apiMessages" + +describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { + let mockClineProvider: ClineProvider + let mockCurrentTask: any + + beforeEach(() => { + vi.clearAllMocks() + + // Create a mock task with messages + mockCurrentTask = { + taskId: "test-task-id", + clineMessages: [] as ClineMessage[], + apiConversationHistory: [] as ApiMessage[], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + handleWebviewAskResponse: vi.fn(), + } + + // Create mock provider + mockClineProvider = { + getCurrentTask: vi.fn().mockReturnValue(mockCurrentTask), + postMessageToWebview: vi.fn(), + contextProxy: { + getValue: vi.fn(), + setValue: vi.fn(), + globalStorageUri: { fsPath: "/mock/storage" }, + }, + log: vi.fn(), + } as unknown as ClineProvider + }) + + it("should not modify API history when apiConversationHistoryIndex is -1", async () => { + // Setup: User message followed by attempt_completion + const userMessageTs = 1000 + const assistantMessageTs = 2000 + const completionMessageTs = 3000 + + // UI messages (clineMessages) + mockCurrentTask.clineMessages = [ + { + ts: userMessageTs, + type: "say", + say: "user_feedback", + text: "Hello", + } as ClineMessage, + { + ts: completionMessageTs, + type: "say", + say: "completion_result", + text: "Task Completed!", + } as ClineMessage, + ] + + // API conversation history - note the user message is missing (common scenario after condense) + mockCurrentTask.apiConversationHistory = [ + { + ts: assistantMessageTs, + role: "assistant", + content: [ + { + type: "text", + text: "I'll help you with that.", + }, + ], + }, + { + ts: completionMessageTs, + role: "assistant", + content: [ + { + type: "tool_use", + name: "attempt_completion", + id: "tool-1", + input: { + result: "Task Completed!", + }, + }, + ], + }, + ] as ApiMessage[] + + // Trigger edit confirmation + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: userMessageTs, + text: "Hello World", // edited content + restoreCheckpoint: false, + }) + + // Verify that UI messages were truncated at the correct index + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith( + [], // All messages before index 0 (empty array) + ) + + // API history should be truncated from first message at/after edited timestamp (fallback) + expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([]) + }) + + it("should preserve messages before the edited message when message not in API history", async () => { + const earlierMessageTs = 500 + const userMessageTs = 1000 + const assistantMessageTs = 2000 + + // UI messages + mockCurrentTask.clineMessages = [ + { + ts: earlierMessageTs, + type: "say", + say: "user_feedback", + text: "Earlier message", + } as ClineMessage, + { + ts: userMessageTs, + type: "say", + say: "user_feedback", + text: "Hello", + } as ClineMessage, + { + ts: assistantMessageTs, + type: "say", + say: "text", + text: "Response", + } as ClineMessage, + ] + + // API history - missing the exact user message at ts=1000 + mockCurrentTask.apiConversationHistory = [ + { + ts: earlierMessageTs, + role: "user", + content: [{ type: "text", text: "Earlier message" }], + }, + { + ts: assistantMessageTs, + role: "assistant", + content: [{ type: "text", text: "Response" }], + }, + ] as ApiMessage[] + + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: userMessageTs, + text: "Hello World", + restoreCheckpoint: false, + }) + + // Verify UI messages were truncated to preserve earlier message + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([ + { + ts: earlierMessageTs, + type: "say", + say: "user_feedback", + text: "Earlier message", + }, + ]) + + // API history should be truncated from the first API message at/after the edited timestamp (fallback) + expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([ + { + ts: earlierMessageTs, + role: "user", + content: [{ type: "text", text: "Earlier message" }], + }, + ]) + }) + + it("should not use fallback when exact apiConversationHistoryIndex is found", async () => { + const userMessageTs = 1000 + const assistantMessageTs = 2000 + + // Both UI and API have the message at the same timestamp + mockCurrentTask.clineMessages = [ + { + ts: userMessageTs, + type: "say", + say: "user_feedback", + text: "Hello", + } as ClineMessage, + { + ts: assistantMessageTs, + type: "say", + say: "text", + text: "Response", + } as ClineMessage, + ] + + mockCurrentTask.apiConversationHistory = [ + { + ts: userMessageTs, + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + { + ts: assistantMessageTs, + role: "assistant", + content: [{ type: "text", text: "Response" }], + }, + ] as ApiMessage[] + + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: userMessageTs, + text: "Hello World", + restoreCheckpoint: false, + }) + + // Both should be truncated at index 0 + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([]) + expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([]) + }) + + it("should handle case where no API messages match timestamp criteria", async () => { + const userMessageTs = 3000 + + mockCurrentTask.clineMessages = [ + { + ts: userMessageTs, + type: "say", + say: "user_feedback", + text: "Hello", + } as ClineMessage, + ] + + // All API messages have timestamps before the edited message + mockCurrentTask.apiConversationHistory = [ + { + ts: 1000, + role: "assistant", + content: [{ type: "text", text: "Old message 1" }], + }, + { + ts: 2000, + role: "assistant", + content: [{ type: "text", text: "Old message 2" }], + }, + ] as ApiMessage[] + + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: userMessageTs, + text: "Hello World", + restoreCheckpoint: false, + }) + + // UI messages truncated + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([]) + + // API history should not be modified when no API messages meet the timestamp criteria + expect(mockCurrentTask.overwriteApiConversationHistory).not.toHaveBeenCalled() + }) + + it("should handle empty API conversation history gracefully", async () => { + const userMessageTs = 1000 + + mockCurrentTask.clineMessages = [ + { + ts: userMessageTs, + type: "say", + say: "user_feedback", + text: "Hello", + } as ClineMessage, + ] + + mockCurrentTask.apiConversationHistory = [] + + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: userMessageTs, + text: "Hello World", + restoreCheckpoint: false, + }) + + // UI messages should be truncated + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([]) + + // API history should not be modified when message not found + expect(mockCurrentTask.overwriteApiConversationHistory).not.toHaveBeenCalled() + }) + + it("should correctly handle attempt_completion in API history", async () => { + const userMessageTs = 1000 + const completionTs = 2000 + const feedbackTs = 3000 + + mockCurrentTask.clineMessages = [ + { + ts: userMessageTs, + type: "say", + say: "user_feedback", + text: "Do something", + } as ClineMessage, + { + ts: completionTs, + type: "say", + say: "completion_result", + text: "Task Completed!", + } as ClineMessage, + { + ts: feedbackTs, + type: "say", + say: "user_feedback", + text: "Thanks", + } as ClineMessage, + ] + + // API history with attempt_completion tool use (user message missing) + mockCurrentTask.apiConversationHistory = [ + { + ts: completionTs, + role: "assistant", + content: [ + { + type: "tool_use", + name: "attempt_completion", + id: "tool-1", + input: { + result: "Task Completed!", + }, + }, + ], + }, + { + ts: feedbackTs, + role: "user", + content: [ + { + type: "text", + text: "Thanks", + }, + ], + }, + ] as ApiMessage[] + + // Edit the first user message + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: userMessageTs, + text: "Do something else", + restoreCheckpoint: false, + }) + + // UI messages truncated at edited message + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([]) + + // API history should be truncated from first message at/after edited timestamp (fallback) + expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([]) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 8b872599bc..d88d10d22a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -84,6 +84,17 @@ export const webviewMessageHandler = async ( return { messageIndex, apiConversationHistoryIndex } } + /** + * Fallback: find first API history index at or after a timestamp. + * Used when the exact user message isn't present in apiConversationHistory (e.g., after condense). + */ + const findFirstApiIndexAtOrAfter = (ts: number, currentCline: any) => { + if (typeof ts !== "number") return -1 + return currentCline.apiConversationHistory.findIndex( + (msg: ApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts, + ) + } + /** * Removes the target message and all subsequent messages */ @@ -109,18 +120,20 @@ export const webviewMessageHandler = async ( // Check if there's a checkpoint before this message const currentCline = provider.getCurrentTask() let hasCheckpoint = false - if (currentCline) { - const { messageIndex } = findMessageIndices(messageTs, currentCline) - if (messageIndex !== -1) { - // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages.filter( - (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, - ) - hasCheckpoint = checkpoints.length > 0 - } else { - console.log("[webviewMessageHandler] Message not found! Looking for ts:", messageTs) - } + if (!currentCline) { + await vscode.window.showErrorMessage(t("common:errors.message.no_active_task_to_delete")) + return + } + + const { messageIndex } = findMessageIndices(messageTs, currentCline) + + if (messageIndex !== -1) { + // Find the last checkpoint before this message + const checkpoints = currentCline.clineMessages.filter( + (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, + ) + hasCheckpoint = checkpoints.length > 0 } // Send message to webview to show delete confirmation dialog @@ -142,11 +155,15 @@ export const webviewMessageHandler = async ( } const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline) + // Determine API truncation index with timestamp fallback if exact match not found + let apiIndexToUse = apiConversationHistoryIndex + const tsThreshold = currentCline.clineMessages[messageIndex]?.ts + if (apiIndexToUse === -1 && typeof tsThreshold === "number") { + apiIndexToUse = findFirstApiIndexAtOrAfter(tsThreshold, currentCline) + } if (messageIndex === -1) { - const errorMessage = `Message with timestamp ${messageTs} not found` - console.error("[handleDeleteMessageConfirm]", errorMessage) - await vscode.window.showErrorMessage(errorMessage) + await vscode.window.showErrorMessage(t("common:errors.message.message_not_found", { messageTs })) return } @@ -188,7 +205,7 @@ export const webviewMessageHandler = async ( } // Delete this message and all subsequent messages - await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) + await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiIndexToUse) // Restore checkpoint associations for preserved messages for (const [ts, checkpoint] of preservedCheckpoints) { @@ -204,11 +221,16 @@ export const webviewMessageHandler = async ( taskId: currentCline.taskId, globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) + + // Update the UI to reflect the deletion + await provider.postStateToWebview() } } catch (error) { console.error("Error in delete message:", error) vscode.window.showErrorMessage( - `Error deleting message: ${error instanceof Error ? error.message : String(error)}`, + t("common:errors.message.error_deleting_message", { + error: error instanceof Error ? error.message : String(error), + }), ) } } @@ -265,7 +287,7 @@ export const webviewMessageHandler = async ( const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline) if (messageIndex === -1) { - const errorMessage = `Message with timestamp ${messageTs} not found` + const errorMessage = t("common:errors.message.message_not_found", { messageTs }) console.error("[handleEditMessageConfirm]", errorMessage) await vscode.window.showErrorMessage(errorMessage) return @@ -308,18 +330,49 @@ export const webviewMessageHandler = async ( } } - // For non-checkpoint edits, preserve checkpoint associations for remaining messages + // For non-checkpoint edits, remove the ORIGINAL user message being edited and all subsequent messages + // Determine the correct starting index to delete from (prefer the last preceding user_feedback message) + let deleteFromMessageIndex = messageIndex + let deleteFromApiIndex = apiConversationHistoryIndex + + // Find the nearest preceding user message to ensure we replace the original, not just the assistant reply + for (let i = messageIndex; i >= 0; i--) { + const m = currentCline.clineMessages[i] + if (m?.say === "user_feedback") { + deleteFromMessageIndex = i + // Align API history truncation to the same user message timestamp if present + const userTs = m.ts + if (typeof userTs === "number") { + const apiIdx = currentCline.apiConversationHistory.findIndex( + (am: ApiMessage) => am.ts === userTs, + ) + if (apiIdx !== -1) { + deleteFromApiIndex = apiIdx + } + } + break + } + } + + // Timestamp fallback for API history when exact user message isn't present + if (deleteFromApiIndex === -1) { + const tsThresholdForEdit = currentCline.clineMessages[deleteFromMessageIndex]?.ts + if (typeof tsThresholdForEdit === "number") { + deleteFromApiIndex = findFirstApiIndexAtOrAfter(tsThresholdForEdit, currentCline) + } + } + // Store checkpoints from messages that will be preserved const preservedCheckpoints = new Map() - for (let i = 0; i < messageIndex; i++) { + for (let i = 0; i < deleteFromMessageIndex; i++) { const msg = currentCline.clineMessages[i] if (msg?.checkpoint && msg.ts) { preservedCheckpoints.set(msg.ts, msg.checkpoint) } } - // Edit this message and delete subsequent - await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) + // Delete the original (user) message and all subsequent messages + await removeMessagesThisAndSubsequent(currentCline, deleteFromMessageIndex, deleteFromApiIndex) // Restore checkpoint associations for preserved messages for (const [ts, checkpoint] of preservedCheckpoints) { @@ -336,20 +389,16 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Process the edited message as a regular user message - webviewMessageHandler(provider, { - type: "askResponse", - askResponse: "messageResponse", - text: editedContent, - images, - }) + // Update the UI to reflect the deletion + await provider.postStateToWebview() - // Don't initialize with history item for edit operations - // The webviewMessageHandler will handle the conversation state + await currentCline.submitUserMessage(editedContent, images) } catch (error) { console.error("Error in edit message:", error) vscode.window.showErrorMessage( - `Error editing message: ${error instanceof Error ? error.message : String(error)}`, + t("common:errors.message.error_editing_message", { + error: error instanceof Error ? error.message : String(error), + }), ) } } @@ -1451,9 +1500,17 @@ export const webviewMessageHandler = async ( } break case "deleteMessage": { - if (provider.getCurrentTask() && typeof message.value === "number" && message.value) { - await handleMessageModificationsOperation(message.value, "delete") + if (!provider.getCurrentTask()) { + await vscode.window.showErrorMessage(t("common:errors.message.no_active_task_to_delete")) + break + } + + if (typeof message.value !== "number" || !message.value) { + await vscode.window.showErrorMessage(t("common:errors.message.invalid_timestamp_for_deletion")) + break } + + await handleMessageModificationsOperation(message.value, "delete") break } case "submitEditedMessage": { @@ -1841,9 +1898,17 @@ export const webviewMessageHandler = async ( } break case "deleteMessageConfirm": - if (message.messageTs) { - await handleDeleteMessageConfirm(message.messageTs, message.restoreCheckpoint) + if (!message.messageTs) { + await vscode.window.showErrorMessage(t("common:errors.message.cannot_delete_missing_timestamp")) + break + } + + if (typeof message.messageTs !== "number") { + await vscode.window.showErrorMessage(t("common:errors.message.cannot_delete_invalid_timestamp")) + break } + + await handleDeleteMessageConfirm(message.messageTs, message.restoreCheckpoint) break case "editMessageConfirm": if (message.messageTs && message.text) { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 379b0d2311..a22850e1d6 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -90,6 +90,15 @@ "apiKeyModelPlanMismatch": "Les claus API i els plans de subscripció permeten models diferents. Assegura't que el model seleccionat estigui inclòs al teu pla.", "notFound": "No s'ha trobat l'executable Claude Code '{{claudePath}}'.\n\nInstal·la Claude Code CLI:\n1. Visita {{installationUrl}} per descarregar Claude Code\n2. Segueix les instruccions d'instal·lació per al teu sistema operatiu\n3. Assegura't que la comanda 'claude' estigui disponible al teu PATH\n4. Alternativament, configura una ruta personalitzada a la configuració de Roo sota 'Ruta de Claude Code'\n\nError original: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "No hi ha cap tasca activa de la qual eliminar missatges", + "invalid_timestamp_for_deletion": "Marca de temps del missatge no vàlida per a l'eliminació", + "cannot_delete_missing_timestamp": "No es pot eliminar el missatge: falta la marca de temps", + "cannot_delete_invalid_timestamp": "No es pot eliminar el missatge: marca de temps no vàlida", + "message_not_found": "Missatge amb marca de temps {{messageTs}} no trobat", + "error_deleting_message": "Error eliminant missatge: {{error}}", + "error_editing_message": "Error editant missatge: {{error}}" + }, "gemini": { "generate_stream": "Error del flux de context de generació de Gemini: {{error}}", "generate_complete_prompt": "Error de finalització de Gemini: {{error}}", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 1d52866e83..02a9737eb0 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API-Schlüssel und Abonnement-Pläne erlauben verschiedene Modelle. Stelle sicher, dass das ausgewählte Modell in deinem Plan enthalten ist.", "notFound": "Claude Code ausführbare Datei '{{claudePath}}' nicht gefunden.\n\nBitte installiere Claude Code CLI:\n1. Besuche {{installationUrl}} um Claude Code herunterzuladen\n2. Folge den Installationsanweisungen für dein Betriebssystem\n3. Stelle sicher, dass der 'claude' Befehl in deinem PATH verfügbar ist\n4. Alternativ konfiguriere einen benutzerdefinierten Pfad in den Roo-Einstellungen unter 'Claude Code Pfad'\n\nUrsprünglicher Fehler: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Keine aktive Aufgabe, aus der Nachrichten gelöscht werden können", + "invalid_timestamp_for_deletion": "Ungültiger Nachrichten-Zeitstempel zum Löschen", + "cannot_delete_missing_timestamp": "Nachricht kann nicht gelöscht werden: fehlender Zeitstempel", + "cannot_delete_invalid_timestamp": "Nachricht kann nicht gelöscht werden: ungültiger Zeitstempel", + "message_not_found": "Nachricht mit Zeitstempel {{messageTs}} nicht gefunden", + "error_deleting_message": "Fehler beim Löschen der Nachricht: {{error}}", + "error_editing_message": "Fehler beim Bearbeiten der Nachricht: {{error}}" + }, "gemini": { "generate_stream": "Fehler beim Generieren des Kontext-Streams von Gemini: {{error}}", "generate_complete_prompt": "Fehler bei der Vervollständigung durch Gemini: {{error}}", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 2ff203bbb5..55ce2016ca 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "No active task to delete messages from", + "invalid_timestamp_for_deletion": "Invalid message timestamp for deletion", + "cannot_delete_missing_timestamp": "Cannot delete message: missing timestamp", + "cannot_delete_invalid_timestamp": "Cannot delete message: invalid timestamp", + "message_not_found": "Message with timestamp {{messageTs}} not found", + "error_deleting_message": "Error deleting message: {{error}}", + "error_editing_message": "Error editing message: {{error}}" + }, "gemini": { "generate_stream": "Gemini generate context stream error: {{error}}", "generate_complete_prompt": "Gemini completion error: {{error}}", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f80efa1a95..7923c927f1 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -87,6 +87,15 @@ "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.", "notFound": "Ejecutable de Claude Code '{{claudePath}}' no encontrado.\n\nPor favor instala Claude Code CLI:\n1. Visita {{installationUrl}} para descargar Claude Code\n2. Sigue las instrucciones de instalación para tu sistema operativo\n3. Asegúrate de que el comando 'claude' esté disponible en tu PATH\n4. Alternativamente, configura una ruta personalizada en la configuración de Roo bajo 'Ruta de Claude Code'\n\nError original: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "No hay tarea activa de la cual eliminar mensajes", + "invalid_timestamp_for_deletion": "Marca de tiempo del mensaje no válida para eliminación", + "cannot_delete_missing_timestamp": "No se puede eliminar el mensaje: falta marca de tiempo", + "cannot_delete_invalid_timestamp": "No se puede eliminar el mensaje: marca de tiempo no válida", + "message_not_found": "Mensaje con marca de tiempo {{messageTs}} no encontrado", + "error_deleting_message": "Error eliminando mensaje: {{error}}", + "error_editing_message": "Error editando mensaje: {{error}}" + }, "gemini": { "generate_stream": "Error del stream de contexto de generación de Gemini: {{error}}", "generate_complete_prompt": "Error de finalización de Gemini: {{error}}", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index ecc0423e36..8a6a592fc8 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -87,6 +87,15 @@ "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.", "notFound": "Exécutable Claude Code '{{claudePath}}' introuvable.\n\nVeuillez installer Claude Code CLI :\n1. Visitez {{installationUrl}} pour télécharger Claude Code\n2. Suivez les instructions d'installation pour votre système d'exploitation\n3. Assurez-vous que la commande 'claude' est disponible dans votre PATH\n4. Alternativement, configurez un chemin personnalisé dans les paramètres Roo sous 'Chemin de Claude Code'\n\nErreur originale : {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Aucune tâche active pour supprimer des messages", + "invalid_timestamp_for_deletion": "Horodatage du message invalide pour la suppression", + "cannot_delete_missing_timestamp": "Impossible de supprimer le message : horodatage manquant", + "cannot_delete_invalid_timestamp": "Impossible de supprimer le message : horodatage invalide", + "message_not_found": "Message avec horodatage {{messageTs}} introuvable", + "error_deleting_message": "Erreur lors de la suppression du message : {{error}}", + "error_editing_message": "Erreur lors de la modification du message : {{error}}" + }, "gemini": { "generate_stream": "Erreur du flux de contexte de génération Gemini : {{error}}", "generate_complete_prompt": "Erreur d'achèvement de Gemini : {{error}}", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 90dcb125d2..24f6f6cf6a 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "संदेशों को हटाने के लिए कोई सक्रिय कार्य नहीं", + "invalid_timestamp_for_deletion": "हटाने के लिए अमान्य संदेश टाइमस्टैम्प", + "cannot_delete_missing_timestamp": "संदेश हटाया नहीं जा सकता: टाइमस्टैम्प गुम है", + "cannot_delete_invalid_timestamp": "संदेश हटाया नहीं जा सकता: अमान्य टाइमस्टैम्प", + "message_not_found": "टाइमस्टैम्प {{messageTs}} वाला संदेश नहीं मिला", + "error_deleting_message": "संदेश हटाने में त्रुटि: {{error}}", + "error_editing_message": "संदेश संपादित करने में त्रुटि: {{error}}" + }, "gemini": { "generate_stream": "जेमिनी जनरेट कॉन्टेक्स्ट स्ट्रीम त्रुटि: {{error}}", "generate_complete_prompt": "जेमिनी समापन त्रुटि: {{error}}", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index e6b9d4f536..51219d3c26 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Tidak ada tugas aktif untuk menghapus pesan", + "invalid_timestamp_for_deletion": "Timestamp pesan tidak valid untuk penghapusan", + "cannot_delete_missing_timestamp": "Tidak dapat menghapus pesan: timestamp tidak ada", + "cannot_delete_invalid_timestamp": "Tidak dapat menghapus pesan: timestamp tidak valid", + "message_not_found": "Pesan dengan timestamp {{messageTs}} tidak ditemukan", + "error_deleting_message": "Error menghapus pesan: {{error}}", + "error_editing_message": "Error mengedit pesan: {{error}}" + }, "gemini": { "generate_stream": "Kesalahan aliran konteks pembuatan Gemini: {{error}}", "generate_complete_prompt": "Kesalahan penyelesaian Gemini: {{error}}", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 4b235ab54b..c3cb7e2626 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Nessuna attività attiva da cui eliminare messaggi", + "invalid_timestamp_for_deletion": "Timestamp del messaggio non valido per l'eliminazione", + "cannot_delete_missing_timestamp": "Impossibile eliminare il messaggio: timestamp mancante", + "cannot_delete_invalid_timestamp": "Impossibile eliminare il messaggio: timestamp non valido", + "message_not_found": "Messaggio con timestamp {{messageTs}} non trovato", + "error_deleting_message": "Errore durante l'eliminazione del messaggio: {{error}}", + "error_editing_message": "Errore durante la modifica del messaggio: {{error}}" + }, "gemini": { "generate_stream": "Errore del flusso di contesto di generazione Gemini: {{error}}", "generate_complete_prompt": "Errore di completamento Gemini: {{error}}", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index bf023eca37..38fa18c6b0 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "メッセージを削除するアクティブなタスクがありません", + "invalid_timestamp_for_deletion": "削除用のメッセージタイムスタンプが無効です", + "cannot_delete_missing_timestamp": "メッセージを削除できません:タイムスタンプがありません", + "cannot_delete_invalid_timestamp": "メッセージを削除できません:タイムスタンプが無効です", + "message_not_found": "タイムスタンプ {{messageTs}} のメッセージが見つかりません", + "error_deleting_message": "メッセージ削除エラー:{{error}}", + "error_editing_message": "メッセージ編集エラー:{{error}}" + }, "gemini": { "generate_stream": "Gemini 生成コンテキスト ストリーム エラー: {{error}}", "generate_complete_prompt": "Gemini 完了エラー: {{error}}", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 33ae49ef2a..9b4f329279 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "메시지를 삭제할 활성 작업이 없습니다", + "invalid_timestamp_for_deletion": "삭제를 위한 메시지 타임스탬프가 유효하지 않습니다", + "cannot_delete_missing_timestamp": "메시지를 삭제할 수 없습니다: 타임스탬프가 없습니다", + "cannot_delete_invalid_timestamp": "메시지를 삭제할 수 없습니다: 타임스탬프가 유효하지 않습니다", + "message_not_found": "타임스탬프 {{messageTs}}인 메시지를 찾을 수 없습니다", + "error_deleting_message": "메시지 삭제 오류: {{error}}", + "error_editing_message": "메시지 편집 오류: {{error}}" + }, "gemini": { "generate_stream": "Gemini 생성 컨텍스트 스트림 오류: {{error}}", "generate_complete_prompt": "Gemini 완료 오류: {{error}}", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 8c4526889b..ded3202ce8 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Geen actieve taak om berichten uit te verwijderen", + "invalid_timestamp_for_deletion": "Ongeldig bericht tijdstempel voor verwijdering", + "cannot_delete_missing_timestamp": "Kan bericht niet verwijderen: tijdstempel ontbreekt", + "cannot_delete_invalid_timestamp": "Kan bericht niet verwijderen: ongeldig tijdstempel", + "message_not_found": "Bericht met tijdstempel {{messageTs}} niet gevonden", + "error_deleting_message": "Fout bij verwijderen van bericht: {{error}}", + "error_editing_message": "Fout bij bewerken van bericht: {{error}}" + }, "gemini": { "generate_stream": "Fout bij het genereren van contextstream door Gemini: {{error}}", "generate_complete_prompt": "Fout bij het voltooien door Gemini: {{error}}", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 35a09be166..f6ad0c204c 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Brak aktywnego zadania do usunięcia wiadomości", + "invalid_timestamp_for_deletion": "Nieprawidłowy znacznik czasu wiadomości do usunięcia", + "cannot_delete_missing_timestamp": "Nie można usunąć wiadomości: brak znacznika czasu", + "cannot_delete_invalid_timestamp": "Nie można usunąć wiadomości: nieprawidłowy znacznik czasu", + "message_not_found": "Wiadomość ze znacznikiem czasu {{messageTs}} nie została znaleziona", + "error_deleting_message": "Błąd usuwania wiadomości: {{error}}", + "error_editing_message": "Błąd edytowania wiadomości: {{error}}" + }, "gemini": { "generate_stream": "Błąd strumienia kontekstu generowania Gemini: {{error}}", "generate_complete_prompt": "Błąd uzupełniania Gemini: {{error}}", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 4d687019ff..e717ceb991 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -91,6 +91,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Nenhuma tarefa ativa para excluir mensagens", + "invalid_timestamp_for_deletion": "Timestamp da mensagem inválido para exclusão", + "cannot_delete_missing_timestamp": "Não é possível excluir mensagem: timestamp ausente", + "cannot_delete_invalid_timestamp": "Não é possível excluir mensagem: timestamp inválido", + "message_not_found": "Mensagem com timestamp {{messageTs}} não encontrada", + "error_deleting_message": "Erro ao excluir mensagem: {{error}}", + "error_editing_message": "Erro ao editar mensagem: {{error}}" + }, "gemini": { "generate_stream": "Erro de fluxo de contexto de geração do Gemini: {{error}}", "generate_complete_prompt": "Erro de conclusão do Gemini: {{error}}", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index df864815fa..7c9da67dfe 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Нет активной задачи для удаления сообщений", + "invalid_timestamp_for_deletion": "Недействительная временная метка сообщения для удаления", + "cannot_delete_missing_timestamp": "Невозможно удалить сообщение: отсутствует временная метка", + "cannot_delete_invalid_timestamp": "Невозможно удалить сообщение: недействительная временная метка", + "message_not_found": "Сообщение с временной меткой {{messageTs}} не найдено", + "error_deleting_message": "Ошибка удаления сообщения: {{error}}", + "error_editing_message": "Ошибка редактирования сообщения: {{error}}" + }, "gemini": { "generate_stream": "Ошибка потока контекста генерации Gemini: {{error}}", "generate_complete_prompt": "Ошибка завершения Gemini: {{error}}", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 5735d7f1ca..8a49fd6f24 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Mesaj silinecek aktif görev yok", + "invalid_timestamp_for_deletion": "Silme için geçersiz mesaj zaman damgası", + "cannot_delete_missing_timestamp": "Mesaj silinemiyor: zaman damgası eksik", + "cannot_delete_invalid_timestamp": "Mesaj silinemiyor: geçersiz zaman damgası", + "message_not_found": "{{messageTs}} zaman damgalı mesaj bulunamadı", + "error_deleting_message": "Mesaj silme hatası: {{error}}", + "error_editing_message": "Mesaj düzenleme hatası: {{error}}" + }, "gemini": { "generate_stream": "Gemini oluşturma bağlam akışı hatası: {{error}}", "generate_complete_prompt": "Gemini tamamlama hatası: {{error}}", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index d11f91f294..942f29eb5a 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -87,6 +87,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "Không có nhiệm vụ hoạt động để xóa tin nhắn", + "invalid_timestamp_for_deletion": "Dấu thời gian tin nhắn không hợp lệ để xóa", + "cannot_delete_missing_timestamp": "Không thể xóa tin nhắn: thiếu dấu thời gian", + "cannot_delete_invalid_timestamp": "Không thể xóa tin nhắn: dấu thời gian không hợp lệ", + "message_not_found": "Không tìm thấy tin nhắn có dấu thời gian {{messageTs}}", + "error_deleting_message": "Lỗi xóa tin nhắn: {{error}}", + "error_editing_message": "Lỗi chỉnh sửa tin nhắn: {{error}}" + }, "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}}", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index e64339cecd..1b3ae2f9c5 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -92,6 +92,15 @@ "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" }, + "message": { + "no_active_task_to_delete": "没有可删除消息的活跃任务", + "invalid_timestamp_for_deletion": "删除操作的消息时间戳无效", + "cannot_delete_missing_timestamp": "无法删除消息:缺少时间戳", + "cannot_delete_invalid_timestamp": "无法删除消息:时间戳无效", + "message_not_found": "未找到时间戳为 {{messageTs}} 的消息", + "error_deleting_message": "删除消息时出错:{{error}}", + "error_editing_message": "编辑消息时出错:{{error}}" + }, "gemini": { "generate_stream": "Gemini 生成上下文流错误:{{error}}", "generate_complete_prompt": "Gemini 完成错误:{{error}}", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index ddf283cf6c..b59dbfa388 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -86,6 +86,15 @@ "apiKeyModelPlanMismatch": "API 金鑰和訂閱方案允許不同的模型。請確保所選模型包含在您的方案中。", "notFound": "找不到 Claude Code 可執行檔案 '{{claudePath}}'。\n\n請安裝 Claude Code CLI:\n1. 造訪 {{installationUrl}} 下載 Claude Code\n2. 依照作業系統的安裝說明進行操作\n3. 確保 'claude' 指令在 PATH 中可用\n4. 或者在 Roo 設定中的 'Claude Code 路徑' 下設定自訂路徑\n\n原始錯誤:{{originalError}}" }, + "message": { + "no_active_task_to_delete": "沒有可刪除訊息的活躍工作", + "invalid_timestamp_for_deletion": "刪除操作的訊息時間戳無效", + "cannot_delete_missing_timestamp": "無法刪除訊息:缺少時間戳", + "cannot_delete_invalid_timestamp": "無法刪除訊息:時間戳無效", + "message_not_found": "未找到時間戳為 {{messageTs}} 的訊息", + "error_deleting_message": "刪除訊息時出錯:{{error}}", + "error_editing_message": "編輯訊息時出錯:{{error}}" + }, "gemini": { "generate_stream": "Gemini 產生內容串流錯誤:{{error}}", "generate_complete_prompt": "Gemini 完成錯誤:{{error}}", From 8d8f0914678f25cf18a623d035975c8c824c40ed Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:42:04 -0500 Subject: [PATCH 08/32] fix: add GIT_EDITOR env var to merge-resolver mode for non-interactive rebase (#7819) --- .roo/rules-merge-resolver/1_workflow.xml | 21 ++++++- .roo/rules-merge-resolver/3_tool_usage.xml | 57 +++++++++++++++++-- .../4_complete_example.xml | 7 ++- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/.roo/rules-merge-resolver/1_workflow.xml b/.roo/rules-merge-resolver/1_workflow.xml index a63809db70..2f0d1162f6 100644 --- a/.roo/rules-merge-resolver/1_workflow.xml +++ b/.roo/rules-merge-resolver/1_workflow.xml @@ -30,12 +30,13 @@ gh pr checkout [PR_NUMBER] --force git fetch origin main - git rebase origin/main + GIT_EDITOR=true git rebase origin/main
Force checkout the PR branch to ensure clean state Fetch the latest main branch Attempt to rebase onto main to reveal conflicts + Use GIT_EDITOR=true to ensure non-interactive rebase
@@ -108,8 +109,8 @@ - git rebase origin/main - Rebase current branch onto main to reveal conflicts + GIT_EDITOR=true git rebase origin/main + Rebase current branch onto main to reveal conflicts (non-interactive) @@ -133,6 +134,20 @@ + + GIT_EDITOR=true git rebase --continue + Continue rebase after resolving conflicts (non-interactive) + + + + + + true + Set to 'true' (a no-op command) to prevent interactive prompts during rebase operations + Prefix git rebase commands with GIT_EDITOR=true to ensure non-interactive execution + + + All merge conflicts have been resolved Resolved files have been staged diff --git a/.roo/rules-merge-resolver/3_tool_usage.xml b/.roo/rules-merge-resolver/3_tool_usage.xml index 35f3b5da75..30e7495574 100644 --- a/.roo/rules-merge-resolver/3_tool_usage.xml +++ b/.roo/rules-merge-resolver/3_tool_usage.xml @@ -26,6 +26,8 @@ Chain git commands with && for efficiency Use --format options for structured output Capture command output for parsing + Use GIT_EDITOR=true for non-interactive git rebase operations + Set environment variables inline to avoid prompts during automation @@ -46,7 +48,7 @@ Rebase onto main to reveal conflicts - git rebase origin/main + GIT_EDITOR=true git rebase origin/main @@ -71,7 +73,7 @@ Continue rebase after resolution - git rebase --continue + GIT_EDITOR=true git rebase --continue @@ -152,7 +154,7 @@ const config = { execute_command - Get PR info with gh CLI execute_command - Checkout PR with gh pr checkout --force execute_command - Fetch origin main - execute_command - Rebase onto origin/main + execute_command - Rebase onto origin/main with GIT_EDITOR=true execute_command - Check for conflicts with git status @@ -178,13 +180,22 @@ const config = { execute_command - Check all conflicts resolved - execute_command - Continue rebase with git rebase --continue + execute_command - Continue rebase with GIT_EDITOR=true git rebase --continue execute_command - Verify clean status + + Git commands waiting for interactive input + + Use GIT_EDITOR=true to bypass editor prompts + Set GIT_SEQUENCE_EDITOR=true for sequence editing + Consider --no-edit flag for commit operations + + + Rebase completes without conflicts @@ -225,4 +236,42 @@ const config = { + + + + Ensuring git operations run without requiring user interaction is critical + for automated conflict resolution. The mode uses environment variables to + bypass interactive prompts. + + + + + Set to 'true' (a no-op command) to skip editor prompts + GIT_EDITOR=true git rebase --continue + During rebase operations that would normally open an editor + + + + Skip interactive rebase todo editing + GIT_SEQUENCE_EDITOR=true git rebase -i HEAD~3 + When interactive rebase is triggered but no editing needed + + + + Use flags to avoid interactive prompts + + git commit --no-edit (use existing message) + git merge --no-edit (skip merge message editing) + git cherry-pick --no-edit (keep original message) + + + + + + Always test commands locally first to identify potential prompts + Combine environment variables when multiple editors might be invoked + Document why non-interactive mode is used in comments + Have fallback strategies if automation fails + + \ No newline at end of file diff --git a/.roo/rules-merge-resolver/4_complete_example.xml b/.roo/rules-merge-resolver/4_complete_example.xml index dae8587997..32b2bf344b 100644 --- a/.roo/rules-merge-resolver/4_complete_example.xml +++ b/.roo/rules-merge-resolver/4_complete_example.xml @@ -54,7 +54,7 @@ From github.com:user/repo -git rebase origin/main +GIT_EDITOR=true git rebase origin/main ]]> -git rebase --continue +GIT_EDITOR=true git rebase --continue ]]> Use git blame and commit messages to understand the history Combine non-conflicting improvements when possible Prioritize bugfixes while accommodating refactors - Complete the rebase process with git rebase --continue + Use GIT_EDITOR=true to ensure non-interactive rebase operations + Complete the rebase process with GIT_EDITOR=true git rebase --continue Validate that both sets of changes work together \ No newline at end of file From 5ca5833b8f6c8c398479ee0b39e48bbcd3685623 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 13:18:33 -0400 Subject: [PATCH 09/32] UI: Render reasoning as plain italic (match ) (#7752) Co-authored-by: Roo Code Co-authored-by: Hannes Rudolph Co-authored-by: daniel-lxs --- webview-ui/src/components/chat/ChatRow.tsx | 8 +- .../src/components/chat/ReasoningBlock.tsx | 113 ++++++------------ 2 files changed, 41 insertions(+), 80 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index f24e2ca660..23ec50af37 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -118,7 +118,6 @@ export const ChatRowContent = ({ const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) - const [reasoningCollapsed, setReasoningCollapsed] = useState(true) const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) const [isEditing, setIsEditing] = useState(false) @@ -1087,9 +1086,10 @@ export const ChatRowContent = ({ return ( setReasoningCollapsed(!reasoningCollapsed)} + ts={message.ts} + isStreaming={isStreaming} + isLast={isLast} + metadata={message.metadata as any} /> ) case "api_req_started": diff --git a/webview-ui/src/components/chat/ReasoningBlock.tsx b/webview-ui/src/components/chat/ReasoningBlock.tsx index baa93485f9..3c981126ef 100644 --- a/webview-ui/src/components/chat/ReasoningBlock.tsx +++ b/webview-ui/src/components/chat/ReasoningBlock.tsx @@ -1,96 +1,57 @@ -import { useCallback, useEffect, useRef, useState } from "react" -import { CaretDownIcon, CaretUpIcon, CounterClockwiseClockIcon } from "@radix-ui/react-icons" +import React, { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import MarkdownBlock from "../common/MarkdownBlock" -import { useMount } from "react-use" +import { Clock, Lightbulb } from "lucide-react" interface ReasoningBlockProps { content: string - elapsed?: number - isCollapsed?: boolean - onToggleCollapse?: () => void + ts: number + isStreaming: boolean + isLast: boolean + metadata?: any } -export const ReasoningBlock = ({ content, elapsed, isCollapsed = false, onToggleCollapse }: ReasoningBlockProps) => { - const contentRef = useRef(null) - const elapsedRef = useRef(0) - const { t } = useTranslation("chat") - const [thought, setThought] = useState() - const [prevThought, setPrevThought] = useState(t("chat:reasoning.thinking")) - const [isTransitioning, setIsTransitioning] = useState(false) - const cursorRef = useRef(0) - const queueRef = useRef([]) +/** + * Render reasoning with a heading and a simple timer. + * - Heading uses i18n key chat:reasoning.thinking + * - Timer runs while reasoning is active (no persistence) + */ +export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => { + const { t } = useTranslation() - useEffect(() => { - if (contentRef.current && !isCollapsed) { - contentRef.current.scrollTop = contentRef.current.scrollHeight - } - }, [content, isCollapsed]) - - useEffect(() => { - if (elapsed) { - elapsedRef.current = elapsed - } - }, [elapsed]) - - // Process the transition queue. - const processNextTransition = useCallback(() => { - const nextThought = queueRef.current.pop() - queueRef.current = [] - - if (nextThought) { - setIsTransitioning(true) - } - - setTimeout(() => { - if (nextThought) { - setPrevThought(nextThought) - setIsTransitioning(false) - } - - setTimeout(() => processNextTransition(), 500) - }, 200) - }, []) - - useMount(() => { - processNextTransition() - }) + const startTimeRef = useRef(Date.now()) + const [elapsed, setElapsed] = useState(0) + // Simple timer that runs while streaming useEffect(() => { - if (content.length - cursorRef.current > 160) { - setThought("... " + content.slice(cursorRef.current)) - cursorRef.current = content.length + if (isLast && isStreaming) { + const tick = () => setElapsed(Date.now() - startTimeRef.current) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) } - }, [content]) + }, [isLast, isStreaming]) - useEffect(() => { - if (thought && thought !== prevThought) { - queueRef.current.push(thought) - } - }, [thought, prevThought]) + const seconds = Math.floor(elapsed / 1000) + const secondsLabel = t("chat:reasoning.seconds", { count: seconds }) return ( -
-
-
- {prevThought} -
-
- {elapsedRef.current > 1000 && ( - <> - -
{t("reasoning.seconds", { count: Math.round(elapsedRef.current / 1000) })}
- - )} - {isCollapsed ? : } +
+
+
+ + {t("chat:reasoning.thinking")}
+ {elapsed > 0 && ( + + + {secondsLabel} + + )}
- {!isCollapsed && ( -
+ {(content?.trim()?.length ?? 0) > 0 && ( +
)} From 150a7b9961f1cae1655477d6abe898bd365941d4 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 13:53:25 -0700 Subject: [PATCH 10/32] Add taskSyncEnabled to userSettingsConfigSchema (#7827) feat: add taskSyncEnabled to userSettingsConfigSchema Co-authored-by: Roo Code --- packages/types/src/cloud.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 827ec2d7da..a566e4ec6a 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -162,6 +162,7 @@ export type UserFeatures = z.infer export const userSettingsConfigSchema = z.object({ extensionBridgeEnabled: z.boolean().optional(), + taskSyncEnabled: z.boolean().optional(), }) export type UserSettingsConfig = z.infer From d0b4b15e118e2f915f87f401f29485dd24c6b99a Mon Sep 17 00:00:00 2001 From: John Richmond <5629+jr@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:08:05 -0700 Subject: [PATCH 11/32] Release: v1.75.0 (#7829) chore: bump version to v1.75.0 --- packages/types/npm/package.metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index ab92224e72..f6140c3977 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.74.0", + "version": "1.75.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", From 7322f519e023c6df1bb5a04cbdfaa6e4c36ba2c7 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:50:37 -0400 Subject: [PATCH 12/32] fix: prevent negative cost values and improve label visibility in evals chart (#7830) Co-authored-by: Roo Code --- apps/web-roo-code/src/app/evals/plot.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web-roo-code/src/app/evals/plot.tsx b/apps/web-roo-code/src/app/evals/plot.tsx index 86c1be3a9a..f68007cd12 100644 --- a/apps/web-roo-code/src/app/evals/plot.tsx +++ b/apps/web-roo-code/src/app/evals/plot.tsx @@ -175,13 +175,13 @@ export const Plot = ({ tableData }: PlotProps) => { <>
Cost x Score
- + Math.round((dataMin - 5) / 5) * 5, + (dataMin: number) => Math.max(0, Math.round((dataMin - 5) / 5) * 5), (dataMax: number) => Math.round((dataMax + 5) / 5) * 5, ]} tickFormatter={(value) => formatCurrency(value)} From 62271c1296dddb7c3782dcee1db28b45d5c5eee3 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 10 Sep 2025 00:50:57 -0400 Subject: [PATCH 13/32] Fix Groq context window display (#7839) --- src/api/providers/__tests__/groq.spec.ts | 2 +- src/api/providers/groq.ts | 13 +------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts index 66bf0690a8..2aee4ea052 100644 --- a/src/api/providers/__tests__/groq.spec.ts +++ b/src/api/providers/__tests__/groq.spec.ts @@ -149,7 +149,7 @@ describe("GroqHandler", () => { expect(firstChunk.done).toBe(false) expect(firstChunk.value).toMatchObject({ type: "usage", - inputTokens: 70, // 100 total - 30 cached + inputTokens: 100, outputTokens: 50, cacheWriteTokens: 0, cacheReadTokens: 30, diff --git a/src/api/providers/groq.ts b/src/api/providers/groq.ts index de07f7c46f..b66e42d7f0 100644 --- a/src/api/providers/groq.ts +++ b/src/api/providers/groq.ts @@ -66,20 +66,9 @@ export class GroqHandler extends BaseOpenAiCompatibleProvider { // Calculate cost using OpenAI-compatible cost calculation const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) - // Calculate non-cached input tokens for proper reporting - const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) - - console.log("usage", { - inputTokens: nonCachedInputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - totalCost, - }) - yield { type: "usage", - inputTokens: nonCachedInputTokens, + inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, From 740924af284ffd418d7626bcb2a7605f38351ca9 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 10 Sep 2025 06:36:10 +0000 Subject: [PATCH 14/32] feat: add DismissibleUpsell component for dismissible messages - Created DismissibleUpsell component with variant support (banner/default) - Added dismissedUpsells to GlobalState for persistence - Implemented message handlers for dismissing and retrieving dismissed upsells - Added comprehensive tests for the component - Uses VSCode extension globalState for persistent storage --- packages/types/src/global-settings.ts | 1 + src/core/webview/webviewMessageHandler.ts | 28 +++ src/shared/ExtensionMessage.ts | 3 + src/shared/WebviewMessage.ts | 4 + .../components/common/DismissibleUpsell.tsx | 144 ++++++++++++++++ .../__tests__/DismissibleUpsell.spec.tsx | 160 ++++++++++++++++++ 6 files changed, 340 insertions(+) create mode 100644 webview-ui/src/components/common/DismissibleUpsell.tsx create mode 100644 webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f1c4b81c48..2ebb3ff634 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -41,6 +41,7 @@ export const globalSettingsSchema = z.object({ lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), taskHistory: z.array(historyItemSchema).optional(), + dismissedUpsells: z.array(z.string()).optional(), // Image generation settings (experimental) - flattened for simplicity openRouterImageApiKey: z.string().optional(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index d88d10d22a..601c4e9b65 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2998,5 +2998,33 @@ export const webviewMessageHandler = async ( break } + case "dismissUpsell": { + if (message.upsellId) { + // Get current list of dismissed upsells + const dismissedUpsells = getGlobalState("dismissedUpsells") || [] + + // Add the new upsell ID if not already present + if (!dismissedUpsells.includes(message.upsellId)) { + const updatedList = [...dismissedUpsells, message.upsellId] + await updateGlobalState("dismissedUpsells", updatedList) + } + + // Send updated list back to webview + await provider.postMessageToWebview({ + type: "dismissedUpsells", + list: [...dismissedUpsells, message.upsellId], + }) + } + break + } + case "getDismissedUpsells": { + // Send the current list of dismissed upsells to the webview + const dismissedUpsells = getGlobalState("dismissedUpsells") || [] + await provider.postMessageToWebview({ + type: "dismissedUpsells", + list: dismissedUpsells, + }) + break + } } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 1565bb8c52..45bc978b83 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -123,6 +123,7 @@ export interface ExtensionMessage { | "showEditMessageDialog" | "commands" | "insertTextIntoTextarea" + | "dismissedUpsells" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -199,6 +200,7 @@ export interface ExtensionMessage { context?: string commands?: Command[] queuedMessages?: QueuedMessage[] + list?: string[] // For dismissedUpsells } export type ExtensionState = Pick< @@ -209,6 +211,7 @@ export type ExtensionState = Pick< // | "lastShownAnnouncementId" | "customInstructions" // | "taskHistory" // Optional in GlobalSettings, required here. + | "dismissedUpsells" | "autoApprovalEnabled" | "alwaysAllowReadOnly" | "alwaysAllowReadOnlyOutsideWorkspace" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index ae8c72dd04..daa6a92eaa 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -222,6 +222,8 @@ export interface WebviewMessage { | "queueMessage" | "removeQueuedMessage" | "editQueuedMessage" + | "dismissUpsell" + | "getDismissedUpsells" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -267,6 +269,8 @@ export interface WebviewMessage { visibility?: ShareVisibility // For share visibility hasContent?: boolean // For checkRulesDirectoryResult checkOnly?: boolean // For deleteCustomMode check + upsellId?: string // For dismissUpsell + list?: string[] // For dismissedUpsells response codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx new file mode 100644 index 0000000000..0721629c22 --- /dev/null +++ b/webview-ui/src/components/common/DismissibleUpsell.tsx @@ -0,0 +1,144 @@ +import { memo, ReactNode, useEffect, useState } from "react" +import styled from "styled-components" + +import { vscode } from "@src/utils/vscode" + +interface DismissibleUpsellProps { + /** Required unique identifier for this upsell */ + className: string + /** Content to display inside the upsell */ + children: ReactNode + /** Visual variant of the upsell */ + variant?: "banner" | "default" + /** Optional callback when upsell is dismissed */ + onDismiss?: () => void +} + +const UpsellContainer = styled.div<{ $variant: "banner" | "default" }>` + position: relative; + padding: 12px 40px 12px 16px; + border-radius: 6px; + margin-bottom: 8px; + display: flex; + align-items: center; + + ${(props) => + props.$variant === "banner" + ? ` + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + ` + : ` + background-color: var(--vscode-notifications-background); + color: var(--vscode-notifications-foreground); + border: 1px solid var(--vscode-notifications-border); + `} +` + +const DismissButton = styled.button<{ $variant: "banner" | "default" }>` + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; + + ${(props) => + props.$variant === "banner" + ? ` + color: var(--vscode-button-foreground); + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + ` + : ` + color: var(--vscode-notifications-foreground); + + &:hover { + background-color: var(--vscode-toolbar-hoverBackground); + } + `} + + &:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + } +` + +const DismissIcon = () => ( + +) + +const DismissibleUpsell = memo(({ className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => { + const [isVisible, setIsVisible] = useState(true) + + useEffect(() => { + // Request the current list of dismissed upsells from the extension + vscode.postMessage({ type: "getDismissedUpsells" }) + + // Listen for the response + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "dismissedUpsells" && Array.isArray(message.list)) { + // Check if this upsell has been dismissed + if (message.list.includes(className)) { + setIsVisible(false) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [className]) + + const handleDismiss = () => { + // Hide the upsell immediately + setIsVisible(false) + + // Notify the extension to persist the dismissal + vscode.postMessage({ + type: "dismissUpsell", + upsellId: className, + }) + + // Call the optional callback + onDismiss?.() + } + + // Don't render if not visible + if (!isVisible) { + return null + } + + return ( + + {children} + + + + + ) +}) + +DismissibleUpsell.displayName = "DismissibleUpsell" + +export default DismissibleUpsell diff --git a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx new file mode 100644 index 0000000000..3d81984bb9 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx @@ -0,0 +1,160 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import DismissibleUpsell from "../DismissibleUpsell" + +// Mock the vscode API +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (message: any) => mockPostMessage(message), + }, +})) + +describe("DismissibleUpsell", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("renders children content", () => { + render( + +
Test content
+
, + ) + + expect(screen.getByText("Test content")).toBeInTheDocument() + }) + + it("applies the correct variant styles", () => { + const { container, rerender } = render( + +
Banner content
+
, + ) + + // Check banner variant has correct background color style + const bannerContainer = container.firstChild + expect(bannerContainer).toHaveStyle({ + backgroundColor: "var(--vscode-button-background)", + color: "var(--vscode-button-foreground)", + }) + + // Re-render with default variant + rerender( + +
Default content
+
, + ) + + const defaultContainer = container.firstChild + expect(defaultContainer).toHaveStyle({ + backgroundColor: "var(--vscode-notifications-background)", + color: "var(--vscode-notifications-foreground)", + }) + }) + + it("requests dismissed upsells list on mount", () => { + render( + +
Test content
+
, + ) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "getDismissedUpsells", + }) + }) + + it("hides the upsell when dismiss button is clicked", async () => { + const onDismiss = vi.fn() + const { container } = render( + +
Test content
+
, + ) + + // Find and click the dismiss button + const dismissButton = screen.getByRole("button", { name: /dismiss/i }) + fireEvent.click(dismissButton) + + // Check that the component is no longer visible + await waitFor(() => { + expect(container.firstChild).toBeNull() + }) + + // Check that the dismiss message was sent + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "dismissUpsell", + upsellId: "test-upsell", + }) + + // Check that the callback was called + expect(onDismiss).toHaveBeenCalled() + }) + + it("hides the upsell if it's in the dismissed list", async () => { + const { container } = render( + +
Test content
+
, + ) + + // Simulate receiving a message that this upsell is dismissed + const messageEvent = new MessageEvent("message", { + data: { + type: "dismissedUpsells", + list: ["test-upsell", "other-upsell"], + }, + }) + window.dispatchEvent(messageEvent) + + // Check that the component is no longer visible + await waitFor(() => { + expect(container.firstChild).toBeNull() + }) + }) + + it("remains visible if not in the dismissed list", async () => { + render( + +
Test content
+
, + ) + + // Simulate receiving a message that doesn't include this upsell + const messageEvent = new MessageEvent("message", { + data: { + type: "dismissedUpsells", + list: ["other-upsell"], + }, + }) + window.dispatchEvent(messageEvent) + + // Check that the component is still visible + await waitFor(() => { + expect(screen.getByText("Test content")).toBeInTheDocument() + }) + }) + + it("applies the className prop to the container", () => { + const { container } = render( + +
Test content
+
, + ) + + expect(container.firstChild).toHaveClass("custom-class") + }) + + it("dismiss button has proper accessibility attributes", () => { + render( + +
Test content
+
, + ) + + const dismissButton = screen.getByRole("button", { name: /dismiss/i }) + expect(dismissButton).toHaveAttribute("aria-label", "Dismiss") + expect(dismissButton).toHaveAttribute("title", "Dismiss and don't show again") + }) +}) From a48e46f8d16b30c9cd0462ae5483dcbbd58d2747 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 10 Sep 2025 08:07:00 +0000 Subject: [PATCH 15/32] fix: Apply PR feedback for DismissibleUpsell component - Changed from className to separate 'id' and 'className' props for better semantics - Added i18n support for accessibility labels (aria-label and title) - Fixed memory leak by adding mounted flag to prevent state updates after unmount - Fixed race condition by sending dismiss message before hiding component - Fixed inefficient array operations in webviewMessageHandler - Added comprehensive test coverage for edge cases including: - Multiple rapid dismissals - Component unmounting during async operations - Invalid/malformed message handling - Proper message sending before unmount - Added null checks for message data to handle edge cases gracefully --- src/core/webview/webviewMessageHandler.ts | 30 +-- .../components/common/DismissibleUpsell.tsx | 46 +++-- .../__tests__/DismissibleUpsell.spec.tsx | 179 ++++++++++++++++-- 3 files changed, 211 insertions(+), 44 deletions(-) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 601c4e9b65..0a1597a32e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3000,20 +3000,26 @@ export const webviewMessageHandler = async ( } case "dismissUpsell": { if (message.upsellId) { - // Get current list of dismissed upsells - const dismissedUpsells = getGlobalState("dismissedUpsells") || [] + try { + // Get current list of dismissed upsells + const dismissedUpsells = getGlobalState("dismissedUpsells") || [] + + // Add the new upsell ID if not already present + let updatedList = dismissedUpsells + if (!dismissedUpsells.includes(message.upsellId)) { + updatedList = [...dismissedUpsells, message.upsellId] + await updateGlobalState("dismissedUpsells", updatedList) + } - // Add the new upsell ID if not already present - if (!dismissedUpsells.includes(message.upsellId)) { - const updatedList = [...dismissedUpsells, message.upsellId] - await updateGlobalState("dismissedUpsells", updatedList) + // Send updated list back to webview (use the already computed updatedList) + await provider.postMessageToWebview({ + type: "dismissedUpsells", + list: updatedList, + }) + } catch (error) { + // Fail silently as per Bruno's comment - it's OK to fail silently in this case + provider.log(`Failed to dismiss upsell: ${error instanceof Error ? error.message : String(error)}`) } - - // Send updated list back to webview - await provider.postMessageToWebview({ - type: "dismissedUpsells", - list: [...dismissedUpsells, message.upsellId], - }) } break } diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx index 0721629c22..b1c0f82001 100644 --- a/webview-ui/src/components/common/DismissibleUpsell.tsx +++ b/webview-ui/src/components/common/DismissibleUpsell.tsx @@ -1,11 +1,14 @@ -import { memo, ReactNode, useEffect, useState } from "react" +import { memo, ReactNode, useEffect, useState, useRef } from "react" import styled from "styled-components" import { vscode } from "@src/utils/vscode" +import { useAppTranslation } from "@src/i18n/TranslationContext" interface DismissibleUpsellProps { /** Required unique identifier for this upsell */ - className: string + id: string + /** Optional CSS class name for styling */ + className?: string /** Content to display inside the upsell */ children: ReactNode /** Visual variant of the upsell */ @@ -84,38 +87,51 @@ const DismissIcon = () => ( ) -const DismissibleUpsell = memo(({ className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => { +const DismissibleUpsell = memo(({ id, className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => { + const { t } = useAppTranslation() const [isVisible, setIsVisible] = useState(true) + const isMountedRef = useRef(true) useEffect(() => { + // Track mounted state + isMountedRef.current = true + // Request the current list of dismissed upsells from the extension vscode.postMessage({ type: "getDismissedUpsells" }) // Listen for the response const handleMessage = (event: MessageEvent) => { + // Only update state if component is still mounted + if (!isMountedRef.current) return + const message = event.data - if (message.type === "dismissedUpsells" && Array.isArray(message.list)) { + // Add null/undefined check for message + if (message && message.type === "dismissedUpsells" && Array.isArray(message.list)) { // Check if this upsell has been dismissed - if (message.list.includes(className)) { + if (message.list.includes(id)) { setIsVisible(false) } } } window.addEventListener("message", handleMessage) - return () => window.removeEventListener("message", handleMessage) - }, [className]) - - const handleDismiss = () => { - // Hide the upsell immediately - setIsVisible(false) + return () => { + isMountedRef.current = false + window.removeEventListener("message", handleMessage) + } + }, [id]) - // Notify the extension to persist the dismissal + const handleDismiss = async () => { + // First notify the extension to persist the dismissal + // This ensures the message is sent even if the component unmounts quickly vscode.postMessage({ type: "dismissUpsell", - upsellId: className, + upsellId: id, }) + // Then hide the upsell + setIsVisible(false) + // Call the optional callback onDismiss?.() } @@ -131,8 +147,8 @@ const DismissibleUpsell = memo(({ className, children, variant = "banner", onDis + aria-label={t("common:dismiss")} + title={t("common:dismissAndDontShowAgain")}> diff --git a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx index 3d81984bb9..78b6c71e90 100644 --- a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx +++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx @@ -1,5 +1,5 @@ -import { render, screen, fireEvent, waitFor } from "@testing-library/react" -import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import DismissibleUpsell from "../DismissibleUpsell" // Mock the vscode API @@ -10,14 +10,32 @@ vi.mock("@src/utils/vscode", () => ({ }, })) +// Mock the translation hook +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "common:dismiss": "Dismiss", + "common:dismissAndDontShowAgain": "Dismiss and don't show again", + } + return translations[key] || key + }, + }), +})) + describe("DismissibleUpsell", () => { beforeEach(() => { mockPostMessage.mockClear() + vi.clearAllTimers() + }) + + afterEach(() => { + vi.clearAllTimers() }) it("renders children content", () => { render( - +
Test content
, ) @@ -27,7 +45,7 @@ describe("DismissibleUpsell", () => { it("applies the correct variant styles", () => { const { container, rerender } = render( - +
Banner content
, ) @@ -41,7 +59,7 @@ describe("DismissibleUpsell", () => { // Re-render with default variant rerender( - +
Default content
, ) @@ -55,7 +73,7 @@ describe("DismissibleUpsell", () => { it("requests dismissed upsells list on mount", () => { render( - +
Test content
, ) @@ -68,7 +86,7 @@ describe("DismissibleUpsell", () => { it("hides the upsell when dismiss button is clicked", async () => { const onDismiss = vi.fn() const { container } = render( - +
Test content
, ) @@ -77,24 +95,24 @@ describe("DismissibleUpsell", () => { const dismissButton = screen.getByRole("button", { name: /dismiss/i }) fireEvent.click(dismissButton) - // Check that the component is no longer visible - await waitFor(() => { - expect(container.firstChild).toBeNull() - }) - - // Check that the dismiss message was sent + // Check that the dismiss message was sent BEFORE hiding expect(mockPostMessage).toHaveBeenCalledWith({ type: "dismissUpsell", upsellId: "test-upsell", }) + // Check that the component is no longer visible + await waitFor(() => { + expect(container.firstChild).toBeNull() + }) + // Check that the callback was called expect(onDismiss).toHaveBeenCalled() }) it("hides the upsell if it's in the dismissed list", async () => { const { container } = render( - +
Test content
, ) @@ -116,7 +134,7 @@ describe("DismissibleUpsell", () => { it("remains visible if not in the dismissed list", async () => { render( - +
Test content
, ) @@ -138,7 +156,7 @@ describe("DismissibleUpsell", () => { it("applies the className prop to the container", () => { const { container } = render( - +
Test content
, ) @@ -148,7 +166,7 @@ describe("DismissibleUpsell", () => { it("dismiss button has proper accessibility attributes", () => { render( - +
Test content
, ) @@ -157,4 +175,131 @@ describe("DismissibleUpsell", () => { expect(dismissButton).toHaveAttribute("aria-label", "Dismiss") expect(dismissButton).toHaveAttribute("title", "Dismiss and don't show again") }) + + // New edge case tests + it("handles multiple rapid dismissals of the same component", async () => { + const onDismiss = vi.fn() + render( + +
Test content
+
, + ) + + const dismissButton = screen.getByRole("button", { name: /dismiss/i }) + + // Click multiple times rapidly + fireEvent.click(dismissButton) + fireEvent.click(dismissButton) + fireEvent.click(dismissButton) + + // Should only send one message + expect(mockPostMessage).toHaveBeenCalledTimes(2) // 1 for getDismissedUpsells, 1 for dismissUpsell + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "dismissUpsell", + upsellId: "test-upsell", + }) + + // Callback should only be called once + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it("does not update state after component unmounts", async () => { + const { unmount } = render( + +
Test content
+
, + ) + + // Unmount the component + unmount() + + // Simulate receiving a message after unmount + const messageEvent = new MessageEvent("message", { + data: { + type: "dismissedUpsells", + list: ["test-upsell"], + }, + }) + + // This should not cause any errors + act(() => { + window.dispatchEvent(messageEvent) + }) + + // No errors should be thrown + expect(true).toBe(true) + }) + + it("handles invalid/malformed messages gracefully", () => { + render( + +
Test content
+
, + ) + + // Send various malformed messages + const malformedMessages = [ + { type: "dismissedUpsells", list: null }, + { type: "dismissedUpsells", list: "not-an-array" }, + { type: "dismissedUpsells" }, // missing list + { type: "wrongType", list: ["test-upsell"] }, + null, + undefined, + "string-message", + ] + + malformedMessages.forEach((data) => { + const messageEvent = new MessageEvent("message", { data }) + window.dispatchEvent(messageEvent) + }) + + // Component should still be visible + expect(screen.getByText("Test content")).toBeInTheDocument() + }) + + it("ensures message is sent before component unmounts on dismiss", async () => { + const { unmount } = render( + +
Test content
+
, + ) + + const dismissButton = screen.getByRole("button", { name: /dismiss/i }) + fireEvent.click(dismissButton) + + // Message should be sent immediately + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "dismissUpsell", + upsellId: "test-upsell", + }) + + // Unmount immediately after clicking + unmount() + + // Message was already sent before unmount + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "dismissUpsell", + upsellId: "test-upsell", + }) + }) + + it("uses separate id and className props correctly", () => { + const { container } = render( + +
Test content
+
, + ) + + // className should be applied to the container + expect(container.firstChild).toHaveClass("styling-class") + + // When dismissed, should use the id, not className + const dismissButton = screen.getByRole("button", { name: /dismiss/i }) + fireEvent.click(dismissButton) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "dismissUpsell", + upsellId: "unique-id", + }) + }) }) From a99397988526451da9507afd92d5aa9de8790bc6 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 10 Sep 2025 10:52:22 +0100 Subject: [PATCH 16/32] New Cloud upsell dialog in task share and cloud view, shared component --- .../src/components/chat/ShareButton.tsx | 42 ++++------ .../components/cloud/CloudUpsellDialog.tsx | 67 +++++++++------ webview-ui/src/components/cloud/CloudView.tsx | 71 +++++----------- .../hooks/__tests__/useCloudUpsell.spec.ts | 84 ------------------- webview-ui/src/hooks/useCloudUpsell.ts | 44 +++++++++- webview-ui/src/i18n/locales/en/cloud.json | 9 +- 6 files changed, 125 insertions(+), 192 deletions(-) delete mode 100644 webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts diff --git a/webview-ui/src/components/chat/ShareButton.tsx b/webview-ui/src/components/chat/ShareButton.tsx index fdc915274e..e53b308844 100644 --- a/webview-ui/src/components/chat/ShareButton.tsx +++ b/webview-ui/src/components/chat/ShareButton.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react" +import { useState, useEffect } from "react" import { useTranslation } from "react-i18next" import { SquareArrowOutUpRightIcon } from "lucide-react" @@ -31,27 +31,23 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share const [shareDropdownOpen, setShareDropdownOpen] = useState(false) const [shareSuccess, setShareSuccess] = useState<{ visibility: ShareVisibility; url: string } | null>(null) const { t } = useTranslation() - const { sharingEnabled, cloudIsAuthenticated, cloudUserInfo } = useExtensionState() - const { isOpen: connectModalOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell() - const wasUnauthenticatedRef = useRef(false) - const initiatedAuthFromThisButtonRef = useRef(false) - - // Track authentication state changes to auto-open popover after login - useEffect(() => { - if (!cloudIsAuthenticated || !sharingEnabled) { - wasUnauthenticatedRef.current = true - } else if (wasUnauthenticatedRef.current && cloudIsAuthenticated && sharingEnabled) { - // Only open dropdown if auth was initiated from this button - if (initiatedAuthFromThisButtonRef.current) { - // User just authenticated from this share button, send telemetry, close modal, and open the popover - telemetryClient.capture(TelemetryEventName.ACCOUNT_CONNECT_SUCCESS) - closeUpsell() - setShareDropdownOpen(true) - initiatedAuthFromThisButtonRef.current = false // Reset the flag - } - wasUnauthenticatedRef.current = false - } - }, [cloudIsAuthenticated, sharingEnabled, closeUpsell]) + const { cloudUserInfo } = useExtensionState() + + // Use enhanced cloud upsell hook with auto-open on auth success + const { + isOpen: connectModalOpen, + openUpsell, + closeUpsell, + handleConnect, + isAuthenticated: cloudIsAuthenticated, + sharingEnabled, + } = useCloudUpsell({ + onAuthSuccess: () => { + // Auto-open share dropdown after successful authentication + setShareDropdownOpen(true) + }, + autoOpenOnAuth: true, + }) // Listen for share success messages from the extension useEffect(() => { @@ -93,8 +89,6 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share } const handleConnectToCloud = () => { - // Mark that authentication was initiated from this button - initiatedAuthFromThisButtonRef.current = true handleConnect() setShareDropdownOpen(false) } diff --git a/webview-ui/src/components/cloud/CloudUpsellDialog.tsx b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx index c814654859..5c4e96ce52 100644 --- a/webview-ui/src/components/cloud/CloudUpsellDialog.tsx +++ b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx @@ -1,5 +1,7 @@ import { useTranslation } from "react-i18next" -import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from "@/components/ui" +import { Dialog, DialogContent, DialogHeader, Button } from "@/components/ui" +import RooHero from "../welcome/RooHero" +import { CircleDollarSign, FileStack, Router, Share } from "lucide-react" interface CloudUpsellDialogProps { open: boolean @@ -7,41 +9,52 @@ interface CloudUpsellDialogProps { onConnect: () => void } +// Reusable method to render cloud benefits content +export const renderCloudBenefitsContent = (t: any) => { + return ( +
+
+ +
+

{t("cloud:cloudBenefitsTitle")}

+
+
    +
  • + + {t("cloud:cloudBenefitWalkaway")} +
  • +
  • + + {t("cloud:cloudBenefitSharing")} +
  • +
  • + + {t("cloud:cloudBenefitMetrics")} +
  • +
  • + + {t("cloud:cloudBenefitHistory")} +
  • +
+

{t("cloud:cloudUpsellPostscript")}

+
+
+ ) +} + export const CloudUpsellDialog = ({ open, onOpenChange, onConnect }: CloudUpsellDialogProps) => { const { t } = useTranslation() return ( - - - {t("cloud:cloudBenefitsTitle")} - - + {/* Intentionally empty */} -
-
-

- {t("cloud:cloudBenefitsSubtitle")} -

-
    -
  • - - {t("cloud:cloudBenefitSharing")} -
  • -
  • - - {t("cloud:cloudBenefitHistory")} -
  • -
  • - - {t("cloud:cloudBenefitMetrics")} -
  • -
-
+
+ {renderCloudBenefitsContent(t)}
-
diff --git a/webview-ui/src/components/cloud/CloudView.tsx b/webview-ui/src/components/cloud/CloudView.tsx index 8ca1a95123..8350aa87ed 100644 --- a/webview-ui/src/components/cloud/CloudView.tsx +++ b/webview-ui/src/components/cloud/CloudView.tsx @@ -8,8 +8,9 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" import { telemetryClient } from "@src/utils/TelemetryClient" import { ToggleSwitch } from "@/components/ui/toggle-switch" +import { renderCloudBenefitsContent } from "./CloudUpsellDialog" -import { History, PiggyBank, SquareArrowOutUpRightIcon } from "lucide-react" +import { cn } from "@/lib/utils" // Define the production URL constant locally to avoid importing from cloud package in tests const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com" @@ -33,8 +34,6 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl const [showManualEntry, setShowManualEntry] = useState(false) const [manualUrl, setManualUrl] = useState("") - const rooLogoUri = (window as any).IMAGES_BASE_URI + "/roo-logo.svg" - // Track authentication state changes to detect successful logout useEffect(() => { if (isAuthenticated) { @@ -145,7 +144,7 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl return (
-

{t("cloud:title")}

+

{isAuthenticated && t("cloud:title")}

{t("settings:common.done")} @@ -218,46 +217,13 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl ) : ( <> -
-
-
- Roo logo -
+
+
+ {renderCloudBenefitsContent(t)}
-
- -
-

- {t("cloud:cloudBenefitsTitle")} -

-
    -
  • - - {t("cloud:cloudBenefitSharing")} -
  • -
  • - - {t("cloud:cloudBenefitHistory")} -
  • -
  • - - {t("cloud:cloudBenefitMetrics")} -
  • -
-
-
{!authInProgress && ( - + {t("cloud:connect")} )} @@ -265,15 +231,15 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl {/* Manual entry section */} {authInProgress && !showManualEntry && ( // Timeout message with "Having trouble?" link -
-
+
+
{t("cloud:authWaiting")}
{!showManualEntry && ( )} @@ -282,8 +248,8 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl {showManualEntry && ( // Manual URL entry form -
-

+

+

{t("cloud:pasteCallbackUrl")}

- +

+ or{" "} + +

)}
diff --git a/webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts b/webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts deleted file mode 100644 index f3490c12b8..0000000000 --- a/webview-ui/src/hooks/__tests__/useCloudUpsell.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { renderHook, act } from "@testing-library/react" -import { vi } from "vitest" -import { useCloudUpsell } from "../useCloudUpsell" -import { TelemetryEventName } from "@roo-code/types" - -// Mock vscode -vi.mock("@/utils/vscode", () => ({ - vscode: { - postMessage: vi.fn(), - }, -})) - -// Mock telemetryClient -vi.mock("@/utils/TelemetryClient", () => ({ - telemetryClient: { - capture: vi.fn(), - }, -})) - -describe("useCloudUpsell", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it("initializes with isOpen as false", () => { - const { result } = renderHook(() => useCloudUpsell()) - expect(result.current.isOpen).toBe(false) - }) - - it("opens the upsell dialog when openUpsell is called", () => { - const { result } = renderHook(() => useCloudUpsell()) - - act(() => { - result.current.openUpsell() - }) - - expect(result.current.isOpen).toBe(true) - }) - - it("closes the upsell dialog when closeUpsell is called", () => { - const { result } = renderHook(() => useCloudUpsell()) - - // First open it - act(() => { - result.current.openUpsell() - }) - expect(result.current.isOpen).toBe(true) - - // Then close it - act(() => { - result.current.closeUpsell() - }) - expect(result.current.isOpen).toBe(false) - }) - - it("handles connect action correctly", async () => { - const { vscode } = await import("@/utils/vscode") - const { telemetryClient } = await import("@/utils/TelemetryClient") - - const { result } = renderHook(() => useCloudUpsell()) - - // Open the dialog first - act(() => { - result.current.openUpsell() - }) - expect(result.current.isOpen).toBe(true) - - // Call handleConnect - act(() => { - result.current.handleConnect() - }) - - // Check that telemetry was sent - expect(telemetryClient.capture).toHaveBeenCalledWith(TelemetryEventName.SHARE_CONNECT_TO_CLOUD_CLICKED) - - // Check that the sign-in message was posted - expect(vscode.postMessage).toHaveBeenCalledWith({ - type: "rooCloudSignIn", - }) - - // Check that the dialog was closed - expect(result.current.isOpen).toBe(false) - }) -}) diff --git a/webview-ui/src/hooks/useCloudUpsell.ts b/webview-ui/src/hooks/useCloudUpsell.ts index a76a898207..1cd18926c0 100644 --- a/webview-ui/src/hooks/useCloudUpsell.ts +++ b/webview-ui/src/hooks/useCloudUpsell.ts @@ -1,10 +1,43 @@ -import { useState, useCallback } from "react" +import { useState, useCallback, useRef, useEffect } from "react" import { TelemetryEventName } from "@roo-code/types" import { vscode } from "@/utils/vscode" import { telemetryClient } from "@/utils/TelemetryClient" +import { useExtensionState } from "@/context/ExtensionStateContext" -export const useCloudUpsell = () => { +interface UseCloudUpsellOptions { + onAuthSuccess?: () => void + autoOpenOnAuth?: boolean +} + +export const useCloudUpsell = (options: UseCloudUpsellOptions = {}) => { + const { onAuthSuccess, autoOpenOnAuth = false } = options const [isOpen, setIsOpen] = useState(false) + const [shouldOpenOnAuth, setShouldOpenOnAuth] = useState(false) + const { cloudIsAuthenticated, sharingEnabled } = useExtensionState() + const wasUnauthenticatedRef = useRef(false) + const initiatedAuthRef = useRef(false) + + // Track authentication state changes + useEffect(() => { + if (!cloudIsAuthenticated || !sharingEnabled) { + wasUnauthenticatedRef.current = true + } else if (wasUnauthenticatedRef.current && cloudIsAuthenticated && sharingEnabled) { + // User just authenticated + if (initiatedAuthRef.current) { + // Auth was initiated from this hook + telemetryClient.capture(TelemetryEventName.ACCOUNT_CONNECT_SUCCESS) + setIsOpen(false) // Close the upsell dialog + + if (autoOpenOnAuth && shouldOpenOnAuth) { + onAuthSuccess?.() + setShouldOpenOnAuth(false) + } + + initiatedAuthRef.current = false // Reset the flag + } + wasUnauthenticatedRef.current = false + } + }, [cloudIsAuthenticated, sharingEnabled, onAuthSuccess, autoOpenOnAuth, shouldOpenOnAuth]) const openUpsell = useCallback(() => { setIsOpen(true) @@ -12,9 +45,14 @@ export const useCloudUpsell = () => { const closeUpsell = useCallback(() => { setIsOpen(false) + setShouldOpenOnAuth(false) }, []) const handleConnect = useCallback(() => { + // Mark that authentication was initiated from this hook + initiatedAuthRef.current = true + setShouldOpenOnAuth(true) + // Send telemetry for connect to cloud action telemetryClient.capture(TelemetryEventName.SHARE_CONNECT_TO_CLOUD_CLICKED) @@ -30,5 +68,7 @@ export const useCloudUpsell = () => { openUpsell, closeUpsell, handleConnect, + isAuthenticated: cloudIsAuthenticated, + sharingEnabled, } } diff --git a/webview-ui/src/i18n/locales/en/cloud.json b/webview-ui/src/i18n/locales/en/cloud.json index c436f50bc4..6e09d0f276 100644 --- a/webview-ui/src/i18n/locales/en/cloud.json +++ b/webview-ui/src/i18n/locales/en/cloud.json @@ -4,12 +4,13 @@ "logOut": "Log out", "testApiAuthentication": "Test API Authentication", "signIn": "Connect to Roo Code Cloud", - "connect": "Connect Now", - "cloudBenefitsTitle": "Connect to Roo Code Cloud", - "cloudBenefitWalkaway": "Follow and control tasks from anywhere with Roomote Control", + "connect": "Get started", + "cloudBenefitsTitle": "Try Roo Code Cloud", + "cloudBenefitWalkaway": "Follow and control tasks from anywhere (including your phone)", "cloudBenefitSharing": "Share tasks with others", - "cloudBenefitHistory": "Access your task history", + "cloudBenefitHistory": "Access your task history from anywhere", "cloudBenefitMetrics": "Get a holistic view of your token consumption", + "cloudUpsellPostscript": "Break free from your IDE and go touch some grass.", "visitCloudWebsite": "Visit Roo Code Cloud", "remoteControl": "Roomote Control", "remoteControlDescription": "Enable following and interacting with tasks in this workspace with Roo Code Cloud", From 50cc8f0bb206580fd9b5801d6be32b28d09a01a3 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 10 Sep 2025 11:54:06 +0100 Subject: [PATCH 17/32] Properly working DismissibleUpsell --- .../components/common/DismissibleUpsell.tsx | 248 +++++++++--------- .../__tests__/DismissibleUpsell.spec.tsx | 188 +++++++++++-- 2 files changed, 290 insertions(+), 146 deletions(-) diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx index b1c0f82001..f0c7359040 100644 --- a/webview-ui/src/components/common/DismissibleUpsell.tsx +++ b/webview-ui/src/components/common/DismissibleUpsell.tsx @@ -1,81 +1,24 @@ import { memo, ReactNode, useEffect, useState, useRef } from "react" -import styled from "styled-components" - import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" interface DismissibleUpsellProps { /** Required unique identifier for this upsell */ - id: string + upsellId: string /** Optional CSS class name for styling */ className?: string /** Content to display inside the upsell */ children: ReactNode /** Visual variant of the upsell */ - variant?: "banner" | "default" + variant?: "default" | "banner" /** Optional callback when upsell is dismissed */ onDismiss?: () => void + /** Optional callback when upsell is clicked */ + onClick?: () => void + /** Whether clicking the upsell should also dismiss it (default: false) */ + dismissOnClick?: boolean } -const UpsellContainer = styled.div<{ $variant: "banner" | "default" }>` - position: relative; - padding: 12px 40px 12px 16px; - border-radius: 6px; - margin-bottom: 8px; - display: flex; - align-items: center; - - ${(props) => - props.$variant === "banner" - ? ` - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - ` - : ` - background-color: var(--vscode-notifications-background); - color: var(--vscode-notifications-foreground); - border: 1px solid var(--vscode-notifications-border); - `} -` - -const DismissButton = styled.button<{ $variant: "banner" | "default" }>` - position: absolute; - top: 50%; - right: 12px; - transform: translateY(-50%); - background: none; - border: none; - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: background-color 0.2s; - - ${(props) => - props.$variant === "banner" - ? ` - color: var(--vscode-button-foreground); - - &:hover { - background-color: rgba(255, 255, 255, 0.1); - } - ` - : ` - color: var(--vscode-notifications-foreground); - - &:hover { - background-color: var(--vscode-toolbar-hoverBackground); - } - `} - - &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 1px; - } -` - const DismissIcon = () => ( ) -const DismissibleUpsell = memo(({ id, className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => { - const { t } = useAppTranslation() - const [isVisible, setIsVisible] = useState(true) - const isMountedRef = useRef(true) - - useEffect(() => { - // Track mounted state - isMountedRef.current = true - - // Request the current list of dismissed upsells from the extension - vscode.postMessage({ type: "getDismissedUpsells" }) - - // Listen for the response - const handleMessage = (event: MessageEvent) => { - // Only update state if component is still mounted - if (!isMountedRef.current) return - - const message = event.data - // Add null/undefined check for message - if (message && message.type === "dismissedUpsells" && Array.isArray(message.list)) { - // Check if this upsell has been dismissed - if (message.list.includes(id)) { - setIsVisible(false) +const DismissibleUpsell = memo( + ({ + upsellId, + className, + children, + variant = "default", + onDismiss, + onClick, + dismissOnClick = false, + }: DismissibleUpsellProps) => { + const { t } = useAppTranslation() + const [isVisible, setIsVisible] = useState(true) + const isMountedRef = useRef(true) + + useEffect(() => { + // Track mounted state + isMountedRef.current = true + + // Request the current list of dismissed upsells from the extension + vscode.postMessage({ type: "getDismissedUpsells" }) + + // Listen for the response + const handleMessage = (event: MessageEvent) => { + // Only update state if component is still mounted + if (!isMountedRef.current) return + + const message = event.data + // Add null/undefined check for message + if (message && message.type === "dismissedUpsells" && Array.isArray(message.list)) { + // Check if this upsell has been dismissed + if (message.list.includes(upsellId)) { + setIsVisible(false) + } } } + + window.addEventListener("message", handleMessage) + return () => { + isMountedRef.current = false + window.removeEventListener("message", handleMessage) + } + }, [upsellId]) + + const handleDismiss = async () => { + // First notify the extension to persist the dismissal + // This ensures the message is sent even if the component unmounts quickly + vscode.postMessage({ + type: "dismissUpsell", + upsellId: upsellId, + }) + + // Then hide the upsell + setIsVisible(false) + + // Call the optional callback + onDismiss?.() } - window.addEventListener("message", handleMessage) - return () => { - isMountedRef.current = false - window.removeEventListener("message", handleMessage) + // Don't render if not visible + if (!isVisible) { + return null } - }, [id]) - - const handleDismiss = async () => { - // First notify the extension to persist the dismissal - // This ensures the message is sent even if the component unmounts quickly - vscode.postMessage({ - type: "dismissUpsell", - upsellId: id, - }) - - // Then hide the upsell - setIsVisible(false) - - // Call the optional callback - onDismiss?.() - } - - // Don't render if not visible - if (!isVisible) { - return null - } - - return ( - - {children} - - - - - ) -}) + + const variants = { + banner: { + container: "p-1 bg-vscode-button-background text-vscode-button-foreground", + button: "text-vscode-button-foreground hover:bg-white/10", + }, + default: { + container: "bg-vscode-notifications-background text-vscode-notifications-foreground", + button: "text-vscode-notifications-foreground hover:bg-vscode-toolbar-hoverBackground", + }, + } + // Build container classes based on variant and presence of click handler + const containerClasses = [ + "relative flex items-start gap-1", + "text-sm ", + variants[variant].container, + onClick && "cursor-pointer hover:opacity-90 transition-opacity duration-200", + className, + ] + .filter(Boolean) + .join(" ") + + // Build button classes based on variant + const buttonClasses = [ + "p-[0.25em] flex items-center justify-center", + "rounded", + "bg-transparent", + "border-none", + "cursor-pointer", + "transition-colors duration-200", + variants[variant].button, + "focus:outline focus:outline-1 focus:outline-vscode-focusBorder focus:outline-offset-1", + ].join(" ") + + return ( +
{ + // Call the onClick handler if provided + onClick?.() + // Also dismiss if dismissOnClick is true + if (dismissOnClick) { + handleDismiss() + } + }}> +
{children}
+ +
+ ) + }, +) DismissibleUpsell.displayName = "DismissibleUpsell" diff --git a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx index 78b6c71e90..9a441b828d 100644 --- a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx +++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx @@ -35,7 +35,7 @@ describe("DismissibleUpsell", () => { it("renders children content", () => { render( - +
Test content
, ) @@ -45,35 +45,33 @@ describe("DismissibleUpsell", () => { it("applies the correct variant styles", () => { const { container, rerender } = render( - +
Banner content
, ) - // Check banner variant has correct background color style - const bannerContainer = container.firstChild - expect(bannerContainer).toHaveStyle({ - backgroundColor: "var(--vscode-button-background)", - color: "var(--vscode-button-foreground)", - }) + // Check banner variant has correct classes + const bannerContainer = container.firstChild as HTMLElement + expect(bannerContainer).toHaveClass("bg-vscode-button-background") + expect(bannerContainer).toHaveClass("text-vscode-button-foreground") // Re-render with default variant rerender( - +
Default content
, ) - const defaultContainer = container.firstChild - expect(defaultContainer).toHaveStyle({ - backgroundColor: "var(--vscode-notifications-background)", - color: "var(--vscode-notifications-foreground)", - }) + const defaultContainer = container.firstChild as HTMLElement + expect(defaultContainer).toHaveClass("bg-vscode-notifications-background") + expect(defaultContainer).toHaveClass("text-vscode-notifications-foreground") + expect(defaultContainer).toHaveClass("border") + expect(defaultContainer).toHaveClass("border-vscode-notifications-border") }) it("requests dismissed upsells list on mount", () => { render( - +
Test content
, ) @@ -86,7 +84,7 @@ describe("DismissibleUpsell", () => { it("hides the upsell when dismiss button is clicked", async () => { const onDismiss = vi.fn() const { container } = render( - +
Test content
, ) @@ -180,7 +178,7 @@ describe("DismissibleUpsell", () => { it("handles multiple rapid dismissals of the same component", async () => { const onDismiss = vi.fn() render( - +
Test content
, ) @@ -205,7 +203,7 @@ describe("DismissibleUpsell", () => { it("does not update state after component unmounts", async () => { const { unmount } = render( - +
Test content
, ) @@ -232,7 +230,7 @@ describe("DismissibleUpsell", () => { it("handles invalid/malformed messages gracefully", () => { render( - +
Test content
, ) @@ -259,7 +257,7 @@ describe("DismissibleUpsell", () => { it("ensures message is sent before component unmounts on dismiss", async () => { const { unmount } = render( - +
Test content
, ) @@ -285,7 +283,7 @@ describe("DismissibleUpsell", () => { it("uses separate id and className props correctly", () => { const { container } = render( - +
Test content
, ) @@ -302,4 +300,152 @@ describe("DismissibleUpsell", () => { upsellId: "unique-id", }) }) + + it("calls onClick when the container is clicked", () => { + const onClick = vi.fn() + render( + +
Test content
+
, + ) + + // Click on the container (not the dismiss button) + const container = screen.getByText("Test content").parentElement as HTMLElement + fireEvent.click(container) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it("does not call onClick when dismiss button is clicked", () => { + const onClick = vi.fn() + const onDismiss = vi.fn() + render( + +
Test content
+
, + ) + + // Click the dismiss button + const dismissButton = screen.getByRole("button", { name: /dismiss/i }) + fireEvent.click(dismissButton) + + // onClick should not be called, but onDismiss should + expect(onClick).not.toHaveBeenCalled() + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it("adds cursor-pointer class when onClick is provided", () => { + const { container, rerender } = render( + {}}> +
Test content
+
, + ) + + // Should have cursor-pointer when onClick is provided + expect(container.firstChild).toHaveClass("cursor-pointer") + + // Re-render without onClick + rerender( + +
Test content
+
, + ) + + // Should not have cursor-pointer when onClick is not provided + expect(container.firstChild).not.toHaveClass("cursor-pointer") + }) + + it("handles both onClick and onDismiss independently", async () => { + const onClick = vi.fn() + const onDismiss = vi.fn() + const { container } = render( + +
Test content
+
, + ) + + // Click on the container + const containerDiv = screen.getByText("Test content").parentElement as HTMLElement + fireEvent.click(containerDiv) + expect(onClick).toHaveBeenCalledTimes(1) + expect(onDismiss).not.toHaveBeenCalled() + + // Reset mocks + onClick.mockClear() + onDismiss.mockClear() + + // Click the dismiss button + const dismissButton = screen.getByRole("button", { name: /dismiss/i }) + fireEvent.click(dismissButton) + + // Only onDismiss should be called + expect(onClick).not.toHaveBeenCalled() + expect(onDismiss).toHaveBeenCalledTimes(1) + + // Component should be hidden after dismiss + await waitFor(() => { + expect(container.firstChild).toBeNull() + }) + }) + + it("dismisses when clicked if dismissOnClick is true", async () => { + const onClick = vi.fn() + const onDismiss = vi.fn() + const { container } = render( + +
Test content
+
, + ) + + const containerDiv = screen.getByText("Test content").parentElement as HTMLElement + fireEvent.click(containerDiv) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onDismiss).toHaveBeenCalledTimes(1) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "dismissUpsell", + upsellId: "test-upsell", + }) + + await waitFor(() => { + expect(container.firstChild).toBeNull() + }) + }) + + it("does not dismiss when clicked if dismissOnClick is false", async () => { + const onClick = vi.fn() + const onDismiss = vi.fn() + render( + +
Test content
+
, + ) + + const containerDiv = screen.getByText("Test content").parentElement as HTMLElement + fireEvent.click(containerDiv) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onDismiss).not.toHaveBeenCalled() + + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "dismissUpsell" })) + expect(screen.getByText("Test content")).toBeInTheDocument() + }) + + it("does not dismiss when clicked if dismissOnClick is not provided (defaults to false)", async () => { + const onClick = vi.fn() + const onDismiss = vi.fn() + render( + +
Test content
+
, + ) + + const containerDiv = screen.getByText("Test content").parentElement as HTMLElement + fireEvent.click(containerDiv) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onDismiss).not.toHaveBeenCalled() + expect(screen.getByText("Test content")).toBeInTheDocument() + }) }) From cdcb7685a17a253cb74490ef85f8e66d8984ca5f Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 10 Sep 2025 13:26:14 +0100 Subject: [PATCH 18/32] Working upsell for long-running tasks --- .../src/components/chat/ShareButton.tsx | 1 + webview-ui/src/components/chat/TaskHeader.tsx | 30 ++++++++++++++++++- .../components/cloud/CloudUpsellDialog.tsx | 6 ++-- .../components/common/DismissibleUpsell.tsx | 16 +++++----- webview-ui/src/hooks/useCloudUpsell.ts | 3 -- webview-ui/src/i18n/locales/en/cloud.json | 6 ++-- 6 files changed, 46 insertions(+), 16 deletions(-) diff --git a/webview-ui/src/components/chat/ShareButton.tsx b/webview-ui/src/components/chat/ShareButton.tsx index e53b308844..e2d3bbae14 100644 --- a/webview-ui/src/components/chat/ShareButton.tsx +++ b/webview-ui/src/components/chat/ShareButton.tsx @@ -100,6 +100,7 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share if (!cloudIsAuthenticated) { // Show modal for unauthenticated users openUpsell() + telemetryClient.capture(TelemetryEventName.SHARE_CONNECT_TO_CLOUD_CLICKED) } else { // Show popover for authenticated users setShareDropdownOpen(true) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 8fd06b168f..b61fb34cd9 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,5 +1,8 @@ -import { memo, useRef, useState } from "react" +import { memo, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" +import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog" +import DismissibleUpsell from "@src/components/common/DismissibleUpsell" import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react" import prettyBytes from "pretty-bytes" @@ -49,6 +52,20 @@ const TaskHeader = ({ const { apiConfiguration, currentTaskItem } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) + const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) + const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({ + autoOpenOnAuth: false, + }) + + useEffect(() => { + const timer = setTimeout(() => { + if (currentTaskItem) { + setShowLongRunningTaskMessage(true) + } + }, 120_000) // Show upsell after 2 minutes + + return () => clearTimeout(timer) + }, [currentTaskItem]) const textContainerRef = useRef(null) const textRef = useRef(null) @@ -69,6 +86,15 @@ const TaskHeader = ({ return (
+ {showLongRunningTaskMessage && ( + openUpsell()} + dismissOnClick={false} + variant="banner"> + {t("chat:cloud.upsell.longRunningTask")} + + )}
+ +
) } diff --git a/webview-ui/src/components/cloud/CloudUpsellDialog.tsx b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx index 5c4e96ce52..4f4a286578 100644 --- a/webview-ui/src/components/cloud/CloudUpsellDialog.tsx +++ b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next" import { Dialog, DialogContent, DialogHeader, Button } from "@/components/ui" import RooHero from "../welcome/RooHero" import { CircleDollarSign, FileStack, Router, Share } from "lucide-react" +import { DialogTitle } from "@radix-ui/react-dialog" interface CloudUpsellDialogProps { open: boolean @@ -36,7 +37,6 @@ export const renderCloudBenefitsContent = (t: any) => { {t("cloud:cloudBenefitHistory")} -

{t("cloud:cloudUpsellPostscript")}

) @@ -48,7 +48,9 @@ export const CloudUpsellDialog = ({ open, onOpenChange, onConnect }: CloudUpsell return ( - {/* Intentionally empty */} + + {/* Intentionally empty */} +
{renderCloudBenefitsContent(t)} diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx index f0c7359040..a2d84cf63d 100644 --- a/webview-ui/src/components/common/DismissibleUpsell.tsx +++ b/webview-ui/src/components/common/DismissibleUpsell.tsx @@ -95,18 +95,18 @@ const DismissibleUpsell = memo( const variants = { banner: { - container: "p-1 bg-vscode-button-background text-vscode-button-foreground", - button: "text-vscode-button-foreground hover:bg-white/10", + container: "p-2 bg-vscode-button-background text-vscode-button-foreground", + button: "text-vscode-button-foreground", }, default: { container: "bg-vscode-notifications-background text-vscode-notifications-foreground", - button: "text-vscode-notifications-foreground hover:bg-vscode-toolbar-hoverBackground", + button: "text-vscode-notifications-foreground", }, } // Build container classes based on variant and presence of click handler const containerClasses = [ - "relative flex items-start gap-1", - "text-sm ", + "relative flex items-start justify-between gap-1", + "text-sm", variants[variant].container, onClick && "cursor-pointer hover:opacity-90 transition-opacity duration-200", className, @@ -116,12 +116,12 @@ const DismissibleUpsell = memo( // Build button classes based on variant const buttonClasses = [ - "p-[0.25em] flex items-center justify-center", + "flex items-center justify-center", "rounded", "bg-transparent", "border-none", "cursor-pointer", - "transition-colors duration-200", + "hover:opacity-50 transition-opacity duration-200", variants[variant].button, "focus:outline focus:outline-1 focus:outline-vscode-focusBorder focus:outline-offset-1", ].join(" ") @@ -137,7 +137,7 @@ const DismissibleUpsell = memo( handleDismiss() } }}> -
{children}
+
{children}
+ + {enabledCount > 7 && ( + <> + openUpsell()} + dismissOnClick={false} + variant="banner"> + {t("chat:cloud.upsell.autoApprovePowerUser")} + + + )}
)} @@ -240,6 +260,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { />
+
) } diff --git a/webview-ui/src/i18n/locales/en/cloud.json b/webview-ui/src/i18n/locales/en/cloud.json index cff9f4ad8d..c7e237d185 100644 --- a/webview-ui/src/i18n/locales/en/cloud.json +++ b/webview-ui/src/i18n/locales/en/cloud.json @@ -19,6 +19,7 @@ "pasteCallbackUrl": "Copy the redirect URL from your browser and paste it here:", "startOver": "Start over", "upsell": { + "autoApprovePowerUser": "Giving Roo some independence? Control it from anywhere with Roo Code Cloud. Click to learn more.", "longRunningTask": "This might take a while. Continue from anywhere with Cloud." } } From effdff00b98651c7473f7a55acfdbaec6be361b8 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 10 Sep 2025 14:11:56 +0100 Subject: [PATCH 20/32] Home page CTA --- .../src/components/chat/AutoApproveMenu.tsx | 2 +- webview-ui/src/components/chat/ChatView.tsx | 39 +++++++++++++++++-- .../components/common/DismissibleUpsell.tsx | 6 ++- .../src/components/welcome/RooCloudCTA.tsx | 23 ----------- webview-ui/src/i18n/locales/en/chat.json | 5 --- webview-ui/src/i18n/locales/en/cloud.json | 3 +- 6 files changed, 43 insertions(+), 35 deletions(-) delete mode 100644 webview-ui/src/components/welcome/RooCloudCTA.tsx diff --git a/webview-ui/src/components/chat/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index ad787dd7bd..e5def20b87 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/webview-ui/src/components/chat/AutoApproveMenu.tsx @@ -189,7 +189,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { onClick={() => openUpsell()} dismissOnClick={false} variant="banner"> - {t("chat:cloud.upsell.autoApprovePowerUser")} + {t("cloud:upsell.autoApprovePowerUser")} )} diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 3e13905bc9..e0528da24c 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -3,10 +3,10 @@ import { useDeepCompareEffect, useEvent, useMount } from "react-use" import debounce from "debounce" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" -import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import useSound from "use-sound" import { LRUCache } from "lru-cache" -import { useTranslation } from "react-i18next" +import { Trans, useTranslation } from "react-i18next" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" @@ -37,10 +37,10 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import RooHero from "@src/components/welcome/RooHero" import RooTips from "@src/components/welcome/RooTips" -import RooCloudCTA from "@src/components/welcome/RooCloudCTA" import { StandardTooltip } from "@src/components/ui" import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState" import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles" +import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog" import TelemetryBanner from "../common/TelemetryBanner" import VersionIndicator from "../common/VersionIndicator" @@ -56,6 +56,9 @@ import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" +import DismissibleUpsell from "../common/DismissibleUpsell" +import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import { Cloud } from "lucide-react" export interface ChatViewProps { isHidden: boolean @@ -208,6 +211,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction { inputValueRef.current = inputValue @@ -1831,7 +1843,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction}
- {cloudIsAuthenticated || taskHistory.length < 4 ? : } + {cloudIsAuthenticated || taskHistory.length < 4 ? ( + + ) : ( + <> + } + onClick={() => openUpsell()} + dismissOnClick={false} + className="bg-vscode-editor-background p-4 !text-base"> + , + }} + /> + + + )}
{/* Show the task history preview if expanded and tasks exist */} {taskHistory.length > 0 && isExpanded && } @@ -2013,6 +2043,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction +
) } diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx index a2d84cf63d..6b310126ad 100644 --- a/webview-ui/src/components/common/DismissibleUpsell.tsx +++ b/webview-ui/src/components/common/DismissibleUpsell.tsx @@ -7,6 +7,8 @@ interface DismissibleUpsellProps { upsellId: string /** Optional CSS class name for styling */ className?: string + /** Optional Icon component */ + icon?: ReactNode /** Content to display inside the upsell */ children: ReactNode /** Visual variant of the upsell */ @@ -34,6 +36,7 @@ const DismissibleUpsell = memo( ({ upsellId, className, + icon, children, variant = "default", onDismiss, @@ -105,7 +108,7 @@ const DismissibleUpsell = memo( } // Build container classes based on variant and presence of click handler const containerClasses = [ - "relative flex items-start justify-between gap-1", + "relative flex items-start justify-between gap-2", "text-sm", variants[variant].container, onClick && "cursor-pointer hover:opacity-90 transition-opacity duration-200", @@ -137,6 +140,7 @@ const DismissibleUpsell = memo( handleDismiss() } }}> + {icon && icon}
{children}
- ) -} - -export default RooCloudCTA diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 72eacc5c58..6afe687a1c 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -354,11 +354,6 @@ "versionIndicator": { "ariaLabel": "Version {{version}} - Click to view release notes" }, - "rooCloudCTA": { - "title": "Roo Code Cloud is evolving!", - "description": "Run Roomote agents in the cloud, access your tasks from anywhere, collaborate with others, and more.", - "joinWaitlist": "Sign up to get the latest updates." - }, "command": { "triggerDescription": "Trigger the {{name}} command" }, diff --git a/webview-ui/src/i18n/locales/en/cloud.json b/webview-ui/src/i18n/locales/en/cloud.json index c7e237d185..efab1a3018 100644 --- a/webview-ui/src/i18n/locales/en/cloud.json +++ b/webview-ui/src/i18n/locales/en/cloud.json @@ -20,6 +20,7 @@ "startOver": "Start over", "upsell": { "autoApprovePowerUser": "Giving Roo some independence? Control it from anywhere with Roo Code Cloud. Click to learn more.", - "longRunningTask": "This might take a while. Continue from anywhere with Cloud." + "longRunningTask": "This might take a while. Continue from anywhere with Cloud.", + "taskList": "Roo Code Cloud is here: follow and control your tasks from anywhere. Learn more." } } From 3b68f712d3980df2761ef3cef5eeb53c7b33ed16 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 10 Sep 2025 14:26:22 +0100 Subject: [PATCH 21/32] Fixes the autoapprove upsell and some tests --- webview-ui/src/components/chat/AutoApproveMenu.tsx | 9 +++++++-- .../common/__tests__/DismissibleUpsell.spec.tsx | 4 ++-- webview-ui/src/i18n/locales/en/cloud.json | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/components/chat/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index e5def20b87..25b936eb74 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/webview-ui/src/components/chat/AutoApproveMenu.tsx @@ -185,11 +185,16 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { {enabledCount > 7 && ( <> openUpsell()} dismissOnClick={false} variant="banner"> - {t("cloud:upsell.autoApprovePowerUser")} + , + }} + /> )} diff --git a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx index 9a441b828d..05aee60f62 100644 --- a/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx +++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx @@ -110,7 +110,7 @@ describe("DismissibleUpsell", () => { it("hides the upsell if it's in the dismissed list", async () => { const { container } = render( - +
Test content
, ) @@ -154,7 +154,7 @@ describe("DismissibleUpsell", () => { it("applies the className prop to the container", () => { const { container } = render( - +
Test content
, ) diff --git a/webview-ui/src/i18n/locales/en/cloud.json b/webview-ui/src/i18n/locales/en/cloud.json index efab1a3018..18656c59bd 100644 --- a/webview-ui/src/i18n/locales/en/cloud.json +++ b/webview-ui/src/i18n/locales/en/cloud.json @@ -19,7 +19,7 @@ "pasteCallbackUrl": "Copy the redirect URL from your browser and paste it here:", "startOver": "Start over", "upsell": { - "autoApprovePowerUser": "Giving Roo some independence? Control it from anywhere with Roo Code Cloud. Click to learn more.", + "autoApprovePowerUser": "Giving Roo some independence? Control it from anywhere with Roo Code Cloud. Learn more.", "longRunningTask": "This might take a while. Continue from anywhere with Cloud.", "taskList": "Roo Code Cloud is here: follow and control your tasks from anywhere. Learn more." } From a9f21c4764b2a92b81a14903d6157bc6da6a4674 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Wed, 10 Sep 2025 14:35:04 +0100 Subject: [PATCH 22/32] Visual and copy fixes --- webview-ui/src/components/chat/TaskHeader.tsx | 2 +- webview-ui/src/components/common/DismissibleUpsell.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index b61fb34cd9..2b170eccbf 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -92,7 +92,7 @@ const TaskHeader = ({ onClick={() => openUpsell()} dismissOnClick={false} variant="banner"> - {t("chat:cloud.upsell.longRunningTask")} + {t("cloud:upsell.longRunningTask")}
)}
{icon && icon} -
{children}
+
{children}
diff --git a/webview-ui/src/components/cloud/CloudView.tsx b/webview-ui/src/components/cloud/CloudView.tsx index 39004d6813..082747d55b 100644 --- a/webview-ui/src/components/cloud/CloudView.tsx +++ b/webview-ui/src/components/cloud/CloudView.tsx @@ -9,7 +9,7 @@ import { vscode } from "@src/utils/vscode" import { telemetryClient } from "@src/utils/TelemetryClient" import { ToggleSwitch } from "@/components/ui/toggle-switch" import { renderCloudBenefitsContent } from "./CloudUpsellDialog" - +import { TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" // Define the production URL constant locally to avoid importing from cloud package in tests @@ -217,12 +217,10 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl ) : ( <>
-
- {renderCloudBenefitsContent(t)} -
+
{renderCloudBenefitsContent(t)}
{!authInProgress && ( - + {t("cloud:connect")} )} @@ -273,8 +271,9 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl )} {cloudApiUrl && cloudApiUrl !== PRODUCTION_ROO_CODE_API_URL && ( -
-
+
+
+ {t("cloud:cloudUrlPillLabel")}:
) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index d89305348e..6cdbeaf0c6 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -32,18 +32,62 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +// Create a variable to hold the mock state +let mockExtensionState: { + apiConfiguration: ProviderSettings + currentTaskItem: { id: string } | null + clineMessages: any[] +} = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id" }, + clineMessages: [], +} + // Mock the ExtensionStateContext vi.mock("@src/context/ExtensionStateContext", () => ({ - useExtensionState: () => ({ - apiConfiguration: { - apiProvider: "anthropic", - apiKey: "test-api-key", // Add relevant fields - apiModelId: "claude-3-opus-20240229", // Add relevant fields - } as ProviderSettings, // Optional: Add type assertion if ProviderSettings is imported - currentTaskItem: { id: "test-task-id" }, + useExtensionState: () => mockExtensionState, +})) + +// Mock the useCloudUpsell hook +vi.mock("@src/hooks/useCloudUpsell", () => ({ + useCloudUpsell: () => ({ + isOpen: false, + openUpsell: vi.fn(), + closeUpsell: vi.fn(), + handleConnect: vi.fn(), }), })) +// Mock DismissibleUpsell component +vi.mock("@src/components/common/DismissibleUpsell", () => ({ + default: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})) + +// Mock CloudUpsellDialog component +vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ + CloudUpsellDialog: () => null, +})) + +// Mock findLastIndex from @roo/array +vi.mock("@roo/array", () => ({ + findLastIndex: (array: any[], predicate: (item: any) => boolean) => { + for (let i = array.length - 1; i >= 0; i--) { + if (predicate(array[i])) { + return i + } + } + return -1 + }, +})) + describe("TaskHeader", () => { const defaultProps: TaskHeaderProps = { task: { type: "say", ts: Date.now(), text: "Test task", images: [] }, @@ -135,4 +179,182 @@ describe("TaskHeader", () => { fireEvent.click(condenseButton!) expect(handleCondenseContext).not.toHaveBeenCalled() }) + + describe("DismissibleUpsell behavior", () => { + beforeEach(() => { + vi.useFakeTimers() + // Reset the mock state before each test + mockExtensionState = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id" }, + clineMessages: [], + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("should show DismissibleUpsell after 2 minutes when task is not complete", async () => { + renderTaskHeader() + + // Initially, the upsell should not be visible + expect(screen.queryByTestId("dismissible-upsell")).not.toBeInTheDocument() + + // Fast-forward time by 2 minutes to match component timeout + await vi.advanceTimersByTimeAsync(120_000) + + // The upsell should now be visible + expect(screen.getByTestId("dismissible-upsell")).toBeInTheDocument() + expect(screen.getByText("cloud:upsell.longRunningTask")).toBeInTheDocument() + }) + + it("should not show DismissibleUpsell when task is complete", async () => { + // Set up mock state with a completion_result message + mockExtensionState = { + ...mockExtensionState, + clineMessages: [ + { + type: "ask", + ask: "completion_result", + ts: Date.now(), + text: "Task completed!", + }, + ], + } + + renderTaskHeader() + + // Fast-forward time by more than 2 minutes + await vi.advanceTimersByTimeAsync(130_000) + + // The upsell should not appear + expect(screen.queryByTestId("dismissible-upsell")).not.toBeInTheDocument() + }) + + it("should not show DismissibleUpsell when currentTaskItem is null", async () => { + // Update the mock state to have null currentTaskItem + mockExtensionState = { + ...mockExtensionState, + currentTaskItem: null, + } + + renderTaskHeader() + + // Fast-forward time by more than 2 minutes + await vi.advanceTimersByTimeAsync(130_000) + + // The upsell should not appear + expect(screen.queryByTestId("dismissible-upsell")).not.toBeInTheDocument() + }) + + it("should not show DismissibleUpsell when task has completion_result in clineMessages", async () => { + // Set up mock state with a completion_result message from the start + mockExtensionState = { + ...mockExtensionState, + clineMessages: [ + { + type: "say", + say: "text", + ts: Date.now() - 1000, + text: "Working on task...", + }, + { + type: "ask", + ask: "completion_result", + ts: Date.now(), + text: "Task completed!", + }, + ], + } + + renderTaskHeader() + + // Fast-forward time by more than 2 minutes + await vi.advanceTimersByTimeAsync(130_000) + + // The upsell should not appear because the task is complete + expect(screen.queryByTestId("dismissible-upsell")).not.toBeInTheDocument() + }) + + it("should not show DismissibleUpsell when task has completion_result followed by resume messages", async () => { + // Set up mock state with a completion_result message followed by resume messages + mockExtensionState = { + ...mockExtensionState, + clineMessages: [ + { + type: "say", + say: "text", + ts: Date.now() - 3000, + text: "Working on task...", + }, + { + type: "ask", + ask: "completion_result", + ts: Date.now() - 2000, + text: "Task completed!", + }, + { + type: "ask", + ask: "resume_completed_task", + ts: Date.now() - 1000, + text: "Resume completed task?", + }, + { + type: "ask", + ask: "resume_task", + ts: Date.now(), + text: "Resume task?", + }, + ], + } + + renderTaskHeader() + + // Fast-forward time by more than 2 minutes + await vi.advanceTimersByTimeAsync(130_000) + + // The upsell should not appear because the last relevant message (skipping resume messages) is completion_result + expect(screen.queryByTestId("dismissible-upsell")).not.toBeInTheDocument() + }) + + it("should show DismissibleUpsell when task has non-completion message followed by resume messages", async () => { + // Set up mock state with a non-completion message followed by resume messages + mockExtensionState = { + ...mockExtensionState, + clineMessages: [ + { + type: "say", + say: "text", + ts: Date.now() - 3000, + text: "Working on task...", + }, + { + type: "ask", + ask: "tool", + ts: Date.now() - 2000, + text: "Need permission to use tool", + }, + { + type: "ask", + ask: "resume_task", + ts: Date.now() - 1000, + text: "Resume task?", + }, + ], + } + + renderTaskHeader() + + // Fast-forward time by 2 minutes to trigger the upsell + await vi.advanceTimersByTimeAsync(120_000) + + // The upsell should appear because the last relevant message (skipping resume messages) is not completion_result + expect(screen.getByTestId("dismissible-upsell")).toBeInTheDocument() + }) + }) })