+ {showLongRunningTaskMessage && !isTaskComplete && (
+
openUpsell()}
+ dismissOnClick={false}
+ variant="banner">
+ {t("cloud:upsell.longRunningTask")}
+
+ )}
+
)
}
diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
index 09d46083d4..f7ba2732fd 100644
--- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
@@ -82,16 +82,10 @@ vi.mock("../Announcement", () => ({
},
}))
-// Mock RooCloudCTA component
-vi.mock("@src/components/welcome/RooCloudCTA", () => ({
- default: function MockRooCloudCTA() {
- return (
-
-
rooCloudCTA.title
-
rooCloudCTA.description
-
rooCloudCTA.joinWaitlist
-
- )
+// Mock DismissibleUpsell component
+vi.mock("@/components/common/DismissibleUpsell", () => ({
+ default: function MockDismissibleUpsell({ children }: { children: React.ReactNode }) {
+ return
{children}
},
}))
@@ -1274,10 +1268,10 @@ describe("ChatView - Version Indicator Tests", () => {
})
})
-describe("ChatView - RooCloudCTA Display Tests", () => {
+describe("ChatView - DismissibleUpsell Display Tests", () => {
beforeEach(() => vi.clearAllMocks())
- it("does not show RooCloudCTA when user is authenticated to Cloud", () => {
+ it("does not show DismissibleUpsell when user is authenticated to Cloud", () => {
const { queryByTestId } = renderChatView()
// Hydrate state with user authenticated to cloud
@@ -1292,11 +1286,11 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Should not show RooCloudCTA when authenticated
- expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
+ // Should not show DismissibleUpsell when authenticated
+ expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
})
- it("does not show RooCloudCTA when user has only run 3 tasks in their history", () => {
+ it("does not show DismissibleUpsell when user has only run 3 tasks in their history", () => {
const { queryByTestId } = renderChatView()
// Hydrate state with user not authenticated but only 3 tasks
@@ -1310,11 +1304,11 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Should not show RooCloudCTA with less than 4 tasks
- expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
+ // Should not show DismissibleUpsell with less than 4 tasks
+ expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
})
- it("shows RooCloudCTA when user is not authenticated and has run 4 or more tasks", async () => {
+ it("shows DismissibleUpsell when user is not authenticated and has run 4 or more tasks", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with user not authenticated and 4 tasks
@@ -1329,13 +1323,13 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Wait for component to render and show RooCloudCTA
+ // Wait for component to render and show DismissibleUpsell
await waitFor(() => {
- expect(getByTestId("roo-cloud-cta")).toBeInTheDocument()
+ expect(getByTestId("dismissible-upsell")).toBeInTheDocument()
})
})
- it("shows RooCloudCTA when user is not authenticated and has run 5 tasks", async () => {
+ it("shows DismissibleUpsell when user is not authenticated and has run 5 tasks", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with user not authenticated and 5 tasks
@@ -1351,13 +1345,13 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Wait for component to render and show RooCloudCTA
+ // Wait for component to render and show DismissibleUpsell
await waitFor(() => {
- expect(getByTestId("roo-cloud-cta")).toBeInTheDocument()
+ expect(getByTestId("dismissible-upsell")).toBeInTheDocument()
})
})
- it("does not show RooCloudCTA when there is an active task (regardless of auth status)", async () => {
+ it("does not show DismissibleUpsell when there is an active task (regardless of auth status)", async () => {
const { queryByTestId } = renderChatView()
// Hydrate state with active task
@@ -1381,8 +1375,8 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
// Wait for component to render with active task
await waitFor(() => {
- // Should not show RooCloudCTA during active task
- expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
+ // Should not show DismissibleUpsell during active task
+ expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
// Should not show RooTips either since the entire welcome screen is hidden during active tasks
expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
// Should not show RooHero either since the entire welcome screen is hidden during active tasks
@@ -1390,7 +1384,7 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
})
})
- it("shows RooTips when user is authenticated (instead of RooCloudCTA)", () => {
+ it("shows RooTips when user is authenticated (instead of DismissibleUpsell)", () => {
const { queryByTestId, getByTestId } = renderChatView()
// Hydrate state with user authenticated to cloud
@@ -1405,12 +1399,12 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Should not show RooCloudCTA but should show RooTips
- expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
+ // Should not show DismissibleUpsell but should show RooTips
+ expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
expect(getByTestId("roo-tips")).toBeInTheDocument()
})
- it("shows RooTips when user has fewer than 4 tasks (instead of RooCloudCTA)", () => {
+ it("shows RooTips when user has fewer than 4 tasks (instead of DismissibleUpsell)", () => {
const { queryByTestId, getByTestId } = renderChatView()
// Hydrate state with user not authenticated but fewer than 4 tasks
@@ -1424,8 +1418,8 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Should not show RooCloudCTA but should show RooTips
- expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
+ // Should not show DismissibleUpsell but should show RooTips
+ expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
expect(getByTestId("roo-tips")).toBeInTheDocument()
})
})
diff --git a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx
index c5f9c2055d..e7b1c85434 100644
--- a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx
@@ -43,11 +43,11 @@ vi.mock("react-i18next", () => ({
"chat:task.connectToCloudDescription": "Sign in to Roo Code Cloud to share tasks",
"chat:task.sharingDisabledByOrganization": "Sharing disabled by organization",
"cloud:cloudBenefitsTitle": "Connect to Roo Code Cloud",
- "cloud:cloudBenefitsSubtitle": "Sign in to Roo Code Cloud to share tasks",
"cloud:cloudBenefitHistory": "Access your task history from anywhere",
"cloud:cloudBenefitSharing": "Share tasks with your team",
"cloud:cloudBenefitMetrics": "Track usage and costs",
"cloud:connect": "Connect",
+ "history:copyPrompt": "Copy",
}
return translations[key] || key
},
@@ -197,7 +197,6 @@ describe("TaskActions", () => {
fireEvent.click(shareButton)
expect(screen.getByText("Connect to Roo Code Cloud")).toBeInTheDocument()
- expect(screen.getByText("Sign in to Roo Code Cloud to share tasks")).toBeInTheDocument()
expect(screen.getByText("Connect")).toBeInTheDocument()
})
@@ -351,30 +350,13 @@ describe("TaskActions", () => {
})
describe("Button States", () => {
- it("keeps share, export, and copy buttons enabled but disables delete button when buttonsDisabled is true", () => {
- render(
)
-
- // Find buttons by their labels/test IDs
- const shareButton = screen.getByTestId("share-button")
- const exportButton = screen.getByLabelText("Export task history")
- const copyButton = screen.getByLabelText("history:copyPrompt")
- const deleteButton = screen.getByLabelText("Delete Task (Shift + Click to skip confirmation)")
-
- // Share, export, and copy buttons should be enabled regardless of buttonsDisabled
- expect(shareButton).not.toBeDisabled()
- expect(exportButton).not.toBeDisabled()
- expect(copyButton).not.toBeDisabled()
- // Delete button should respect buttonsDisabled
- expect(deleteButton).toBeDisabled()
- })
-
it("share, export, and copy buttons are always enabled while delete button respects buttonsDisabled state", () => {
// Test with buttonsDisabled = false
const { rerender } = render(
)
let shareButton = screen.getByTestId("share-button")
let exportButton = screen.getByLabelText("Export task history")
- let copyButton = screen.getByLabelText("history:copyPrompt")
+ let copyButton = screen.getByLabelText("Copy")
let deleteButton = screen.getByLabelText("Delete Task (Shift + Click to skip confirmation)")
expect(shareButton).not.toBeDisabled()
@@ -387,7 +369,7 @@ describe("TaskActions", () => {
shareButton = screen.getByTestId("share-button")
exportButton = screen.getByLabelText("Export task history")
- copyButton = screen.getByLabelText("history:copyPrompt")
+ copyButton = screen.getByLabelText("Copy")
deleteButton = screen.getByLabelText("Delete Task (Shift + Click to skip confirmation)")
// Share, export, and copy remain enabled
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()
+ })
+ })
})
diff --git a/webview-ui/src/components/cloud/CloudUpsellDialog.tsx b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx
new file mode 100644
index 0000000000..6f1d8e7481
--- /dev/null
+++ b/webview-ui/src/components/cloud/CloudUpsellDialog.tsx
@@ -0,0 +1,67 @@
+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
+ onOpenChange: (open: boolean) => void
+ 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")}
+
+
+
+
+ )
+}
+
+export const CloudUpsellDialog = ({ open, onOpenChange, onConnect }: CloudUpsellDialogProps) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {/* Intentionally empty */}
+
+
+
+ {renderCloudBenefitsContent(t)}
+
+
+
+ {t("cloud:connect")}
+
+
+
+
+
+ )
+}
diff --git a/webview-ui/src/components/cloud/CloudView.tsx b/webview-ui/src/components/cloud/CloudView.tsx
index 5841f2efe9..a89d3ee0d3 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 { History, PiggyBank, SquareArrowOutUpRightIcon } from "lucide-react"
+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
const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com"
@@ -33,14 +34,11 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
const wasAuthenticatedRef = useRef(false)
const timeoutRef = useRef
(null)
const manualUrlInputRef = useRef(null)
-
// Manual URL entry state
const [authInProgress, setAuthInProgress] = useState(false)
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) {
@@ -157,7 +155,7 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
return (
-
{t("cloud:title")}
+
{isAuthenticated && t("cloud:title")}
{t("settings:common.done")}
@@ -268,46 +266,11 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
>
) : (
<>
-
-
-
-
-
-
-
+
+
{renderCloudBenefitsContent(t)}
-
-
- {t("cloud:cloudBenefitsTitle")}
-
-
-
-
- {t("cloud:cloudBenefitSharing")}
-
-
-
- {t("cloud:cloudBenefitHistory")}
-
-
-
- {t("cloud:cloudBenefitMetrics")}
-
-
-
-
-
{!authInProgress && (
-
+
{t("cloud:connect")}
)}
@@ -315,15 +278,15 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
{/* Manual entry section */}
{authInProgress && !showManualEntry && (
// Timeout message with "Having trouble?" link
-
-
+
+
{t("cloud:authWaiting")}
{!showManualEntry && (
+ className="text-base ml-5 text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground underline cursor-pointer bg-transparent border-none p-0">
{t("cloud:havingTrouble")}
)}
@@ -332,8 +295,8 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
{showManualEntry && (
// Manual URL entry form
-
-
+
+
{t("cloud:pasteCallbackUrl")}
-
- {t("cloud:startOver")}
-
+
+ or{" "}
+
+ {t("cloud:startOver")}
+
+
)}
>
)}
{cloudApiUrl && cloudApiUrl !== PRODUCTION_ROO_CODE_API_URL && (
-
-
+
+
+
{t("cloud:cloudUrlPillLabel")}:
({
+ useTranslation: () => ({
+ t: (key: string) => {
+ const translations: Record = {
+ "cloud:cloudBenefitsTitle": "Connect to Roo Cloud",
+ "cloud:cloudBenefitSharing": "Share tasks with your team",
+ "cloud:cloudBenefitHistory": "Access conversation history",
+ "cloud:cloudBenefitMetrics": "View usage metrics",
+ "cloud:cloudBenefitWalkaway": "Walk away with your code",
+ "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("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 as list items", () => {
+ render( )
+
+ const listItems = screen.getAllByRole("listitem")
+ expect(listItems).toHaveLength(4)
+ })
+})
diff --git a/webview-ui/src/components/common/DismissibleUpsell.tsx b/webview-ui/src/components/common/DismissibleUpsell.tsx
new file mode 100644
index 0000000000..2eefb89972
--- /dev/null
+++ b/webview-ui/src/components/common/DismissibleUpsell.tsx
@@ -0,0 +1,163 @@
+import { memo, ReactNode, useEffect, useState, useRef } from "react"
+import { vscode } from "@src/utils/vscode"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
+
+interface DismissibleUpsellProps {
+ /** Required unique identifier for this upsell */
+ 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 */
+ 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 DismissIcon = () => (
+
+
+
+)
+
+const DismissibleUpsell = memo(
+ ({
+ upsellId,
+ className,
+ icon,
+ children,
+ variant = "default",
+ onDismiss,
+ onClick,
+ dismissOnClick = false,
+ }: DismissibleUpsellProps) => {
+ const { t } = useAppTranslation()
+ const [isVisible, setIsVisible] = useState(false)
+ 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(true)
+ }
+ }
+ }
+
+ 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?.()
+ }
+
+ // Don't render if not visible
+ if (!isVisible) {
+ return null
+ }
+
+ const variants = {
+ banner: {
+ container:
+ "p-2 bg-vscode-badge-background/80 text-vscode-badge-foreground border-vscode-dropdown-border border",
+ button: "text-vscode-badge-foreground",
+ },
+ default: {
+ container: "bg-vscode-notifications-background text-vscode-notifications-foreground",
+ button: "text-vscode-notifications-foreground",
+ },
+ }
+ // Build container classes based on variant and presence of click handler
+ const containerClasses = [
+ "relative flex items-start justify-between gap-2",
+ "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 = [
+ "flex items-center justify-center",
+ "rounded",
+ "bg-transparent",
+ "border-none",
+ "cursor-pointer",
+ "hover:opacity-50 transition-opacity 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()
+ }
+ }}>
+ {icon && icon}
+
{children}
+
{
+ e.stopPropagation() // Prevent triggering the container's onClick
+ handleDismiss()
+ }}
+ aria-label={t("common:dismiss")}
+ title={t("common:dismissAndDontShowAgain")}>
+
+
+
+ )
+ },
+)
+
+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..3af66dfdf1
--- /dev/null
+++ b/webview-ui/src/components/common/__tests__/DismissibleUpsell.spec.tsx
@@ -0,0 +1,557 @@
+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
+const mockPostMessage = vi.fn()
+vi.mock("@src/utils/vscode", () => ({
+ vscode: {
+ postMessage: (message: any) => mockPostMessage(message),
+ },
+}))
+
+// 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()
+ })
+
+ // Helper function to make the component visible
+ const makeUpsellVisible = () => {
+ const messageEvent = new MessageEvent("message", {
+ data: {
+ type: "dismissedUpsells",
+ list: [], // Empty list means no upsells are dismissed
+ },
+ })
+ window.dispatchEvent(messageEvent)
+ }
+
+ it("renders children content", async () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ // Component starts hidden, make it visible
+ makeUpsellVisible()
+
+ // Wait for component to become visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+ })
+
+ 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
+ ,
+ )
+
+ // Make component visible first
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ // Find and click the dismiss button
+ const dismissButton = screen.getByRole("button", { name: /dismiss/i })
+ fireEvent.click(dismissButton)
+
+ // 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
+ ,
+ )
+
+ // Component starts hidden by default
+ expect(container.firstChild).toBeNull()
+
+ // 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 remains hidden
+ 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", async () => {
+ const { container } = render(
+
+ Test content
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(container.firstChild).not.toBeNull()
+ })
+
+ expect(container.firstChild).toHaveClass("custom-class")
+ })
+
+ it("dismiss button has proper accessibility attributes", async () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ const dismissButton = screen.getByRole("button", { name: /dismiss/i })
+ 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
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ 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", async () => {
+ render(
+
+ Test content
+ ,
+ )
+
+ // First make it visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ // 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
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ 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", async () => {
+ const { container } = render(
+
+ Test content
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(container.firstChild).not.toBeNull()
+ })
+
+ // 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",
+ })
+ })
+
+ it("calls onClick when the container is clicked", async () => {
+ const onClick = vi.fn()
+ render(
+
+ Test content
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ // 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", async () => {
+ const onClick = vi.fn()
+ const onDismiss = vi.fn()
+ render(
+
+ Test content
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ // 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", async () => {
+ const { container, rerender } = render(
+ {}}>
+ Test content
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(container.firstChild).not.toBeNull()
+ })
+
+ // 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
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ // 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
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ 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
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ 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
+ ,
+ )
+
+ // Make component visible
+ makeUpsellVisible()
+
+ // Wait for component to be visible
+ await waitFor(() => {
+ expect(screen.getByText("Test content")).toBeInTheDocument()
+ })
+
+ 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()
+ })
+})
diff --git a/webview-ui/src/components/welcome/RooCloudCTA.tsx b/webview-ui/src/components/welcome/RooCloudCTA.tsx
deleted file mode 100644
index c116cdbc3c..0000000000
--- a/webview-ui/src/components/welcome/RooCloudCTA.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useTranslation } from "react-i18next"
-
-export function RooCloudCTA() {
- const { t } = useTranslation("chat")
-
- return (
-
- )
-}
-
-export default RooCloudCTA
diff --git a/webview-ui/src/hooks/useCloudUpsell.ts b/webview-ui/src/hooks/useCloudUpsell.ts
new file mode 100644
index 0000000000..1476a5a83e
--- /dev/null
+++ b/webview-ui/src/hooks/useCloudUpsell.ts
@@ -0,0 +1,71 @@
+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"
+
+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)
+ }, [])
+
+ const closeUpsell = useCallback(() => {
+ setIsOpen(false)
+ setShouldOpenOnAuth(false)
+ }, [])
+
+ const handleConnect = useCallback(() => {
+ // Mark that authentication was initiated from this hook
+ initiatedAuthRef.current = true
+ setShouldOpenOnAuth(true)
+
+ // Send message to VS Code to initiate sign in
+ vscode.postMessage({ type: "rooCloudSignIn" })
+
+ // Close the upsell dialog
+ closeUpsell()
+ }, [closeUpsell])
+
+ return {
+ isOpen,
+ openUpsell,
+ closeUpsell,
+ handleConnect,
+ isAuthenticated: cloudIsAuthenticated,
+ sharingEnabled,
+ }
+}
diff --git a/webview-ui/src/i18n/locales/ca/cloud.json b/webview-ui/src/i18n/locales/ca/cloud.json
index 80c8289574..ec2a989ae7 100644
--- a/webview-ui/src/i18n/locales/ca/cloud.json
+++ b/webview-ui/src/i18n/locales/ca/cloud.json
@@ -22,5 +22,10 @@
"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"
+ "startOver": "Torna a començar",
+ "upsell": {
+ "autoApprovePowerUser": "Donant-li una mica d'independència a Roo? Controla'l des de qualsevol lloc amb Roo Code Cloud. Més informació .",
+ "longRunningTask": "Això pot trigar una estona. Continua des de qualsevol lloc amb Cloud.",
+ "taskList": "Roo Code Cloud ja és aquí: segueix i controla les teves tasques des de qualsevol lloc. Més informació ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/de/cloud.json b/webview-ui/src/i18n/locales/de/cloud.json
index 9273497e85..1f83e70bad 100644
--- a/webview-ui/src/i18n/locales/de/cloud.json
+++ b/webview-ui/src/i18n/locales/de/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Probleme?",
"pasteCallbackUrl": "Kopiere die Redirect-URL aus deinem Browser und füge sie hier ein:",
"startOver": "Von vorne beginnen",
- "cloudUrlPillLabel": "Roo Code Cloud URL"
+ "cloudUrlPillLabel": "Roo Code Cloud URL",
+ "upsell": {
+ "autoApprovePowerUser": "Roo etwas Unabhängigkeit geben? Kontrolliere es von überall mit Roo Code Cloud. Mehr erfahren .",
+ "longRunningTask": "Das könnte eine Weile dauern. Mit Cloud von überall weitermachen.",
+ "taskList": "Roo Code Cloud ist hier: Verfolge und kontrolliere deine Aufgaben von überall. Mehr erfahren ."
+ }
}
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 dde103a2c2..b8afcc4db4 100644
--- a/webview-ui/src/i18n/locales/en/cloud.json
+++ b/webview-ui/src/i18n/locales/en/cloud.json
@@ -4,11 +4,11 @@
"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",
"visitCloudWebsite": "Visit Roo Code Cloud",
"taskSync": "Task sync",
@@ -22,5 +22,10 @@
"authWaiting": "Waiting for browser authentication...",
"havingTrouble": "Having trouble?",
"pasteCallbackUrl": "Copy the redirect URL from your browser and paste it here:",
- "startOver": "Start over"
+ "startOver": "Start over",
+ "upsell": {
+ "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 ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/es/cloud.json b/webview-ui/src/i18n/locales/es/cloud.json
index 0d831862ef..80d0dc4705 100644
--- a/webview-ui/src/i18n/locales/es/cloud.json
+++ b/webview-ui/src/i18n/locales/es/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "¿Tienes problemas?",
"pasteCallbackUrl": "Copia la URL de redirect desde tu navegador y pégala aquí:",
"startOver": "Empezar de nuevo",
- "cloudUrlPillLabel": "URL de Roo Code Cloud"
+ "cloudUrlPillLabel": "URL de Roo Code Cloud",
+ "upsell": {
+ "autoApprovePowerUser": "¿Dándole a Roo un poco de independencia? Contrólalo desde cualquier lugar con Roo Code Cloud. Saber más .",
+ "longRunningTask": "Esto podría tardar un poco. Continúa desde cualquier lugar con la Nube.",
+ "taskList": "Roo Code Cloud ya está aquí: sigue y controla tus tareas desde cualquier lugar. Saber más ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/fr/cloud.json b/webview-ui/src/i18n/locales/fr/cloud.json
index 9bff2d63b0..8f32a2628d 100644
--- a/webview-ui/src/i18n/locales/fr/cloud.json
+++ b/webview-ui/src/i18n/locales/fr/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Des difficultés ?",
"pasteCallbackUrl": "Copie l'URL de redirect depuis ton navigateur et colle-la ici :",
"startOver": "Recommencer",
- "cloudUrlPillLabel": "URL de Roo Code Cloud"
+ "cloudUrlPillLabel": "URL de Roo Code Cloud",
+ "upsell": {
+ "autoApprovePowerUser": "Donner à Roo un peu d'indépendance ? Contrôlez-le de n'importe où avec Roo Code Cloud. En savoir plus .",
+ "longRunningTask": "Cela peut prendre un certain temps. Continuez de n'importe où avec le Cloud.",
+ "taskList": "Roo Code Cloud est là : suivez et contrôlez vos tâches de n'importe où. En savoir plus ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/hi/cloud.json b/webview-ui/src/i18n/locales/hi/cloud.json
index 8cf0cea57e..2d896575bb 100644
--- a/webview-ui/src/i18n/locales/hi/cloud.json
+++ b/webview-ui/src/i18n/locales/hi/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "समस्या हो रही है?",
"pasteCallbackUrl": "अपने ब्राउज़र से redirect URL कॉपी करें और यहाँ पेस्ट करें:",
"startOver": "फिर से शुरू करें",
- "cloudUrlPillLabel": "Roo Code Cloud URL"
+ "cloudUrlPillLabel": "Roo Code Cloud URL",
+ "upsell": {
+ "autoApprovePowerUser": "रू को थोड़ी स्वतंत्रता दे रहे हैं? रू कोड क्लाउड के साथ इसे कहीं से भी नियंत्रित करें। और जानें ।",
+ "longRunningTask": "इसमें थोड़ा समय लग सकता है। क्लाउड के साथ कहीं से भी जारी रखें।",
+ "taskList": "रू कोड क्लाउड यहाँ है: कहीं से भी अपने कार्यों का पालन और नियंत्रण करें। और जानें ।"
+ }
}
diff --git a/webview-ui/src/i18n/locales/id/cloud.json b/webview-ui/src/i18n/locales/id/cloud.json
index c5390d0ce5..8af9b197ed 100644
--- a/webview-ui/src/i18n/locales/id/cloud.json
+++ b/webview-ui/src/i18n/locales/id/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Ada masalah?",
"pasteCallbackUrl": "Salin URL redirect dari browser dan tempel di sini:",
"startOver": "Mulai dari awal",
- "cloudUrlPillLabel": "URL Roo Code Cloud"
+ "cloudUrlPillLabel": "URL Roo Code Cloud",
+ "upsell": {
+ "autoApprovePowerUser": "Memberi Roo sedikit kebebasan? Kendalikan dari mana saja dengan Roo Code Cloud. Pelajari lebih lanjut .",
+ "longRunningTask": "Ini mungkin akan memakan waktu cukup lama. Lanjutkan dari mana saja dengan Cloud.",
+ "taskList": "Roo Code Cloud ada di sini: ikuti dan kendalikan tugas Anda dari mana saja. Pelajari lebih lanjut ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/it/cloud.json b/webview-ui/src/i18n/locales/it/cloud.json
index bfb9c74f7a..422a1a688b 100644
--- a/webview-ui/src/i18n/locales/it/cloud.json
+++ b/webview-ui/src/i18n/locales/it/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Hai problemi?",
"pasteCallbackUrl": "Copia l'URL di redirect dal tuo browser e incollalo qui:",
"startOver": "Ricomincia",
- "cloudUrlPillLabel": "URL di Roo Code Cloud"
+ "cloudUrlPillLabel": "URL di Roo Code Cloud",
+ "upsell": {
+ "autoApprovePowerUser": "Vuoi dare un po' di indipendenza a Roo? Controllalo da qualsiasi luogo con Roo Code Cloud. Scopri di più .",
+ "longRunningTask": "Potrebbe volerci un po' di tempo. Continua da qualsiasi luogo con il Cloud.",
+ "taskList": "Roo Code Cloud è qui: segui e controlla le tue attività da qualsiasi luogo. Scopri di più ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/ja/cloud.json b/webview-ui/src/i18n/locales/ja/cloud.json
index 90d5f61881..6bdf6f886e 100644
--- a/webview-ui/src/i18n/locales/ja/cloud.json
+++ b/webview-ui/src/i18n/locales/ja/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "問題が発生していますか?",
"pasteCallbackUrl": "ブラウザからリダイレクトURLをコピーし、ここに貼り付けてください:",
"startOver": "最初からやり直す",
- "cloudUrlPillLabel": "Roo Code Cloud URL"
+ "cloudUrlPillLabel": "Roo Code Cloud URL",
+ "upsell": {
+ "autoApprovePowerUser": "Rooに少し独立性を与えませんか?Roo Code Cloudでどこからでもコントロールできます。詳細 。",
+ "longRunningTask": "これには時間がかかるかもしれません。Cloudを使えばどこからでも続けられます。",
+ "taskList": "Roo Code Cloudが登場しました:どこからでもタスクを追跡し、コントロールできます。詳細 。"
+ }
}
diff --git a/webview-ui/src/i18n/locales/ko/cloud.json b/webview-ui/src/i18n/locales/ko/cloud.json
index 32017672a5..763947da3e 100644
--- a/webview-ui/src/i18n/locales/ko/cloud.json
+++ b/webview-ui/src/i18n/locales/ko/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "문제가 있나요?",
"pasteCallbackUrl": "브라우저에서 리다이렉트 URL을 복사하여 여기에 붙여넣으세요:",
"startOver": "다시 시작",
- "cloudUrlPillLabel": "Roo Code Cloud URL"
+ "cloudUrlPillLabel": "Roo Code Cloud URL",
+ "upsell": {
+ "autoApprovePowerUser": "Roo에게 약간의 독립성을 부여하시겠습니까? Roo Code Cloud로 어디서든 제어하세요. 더 알아보기 .",
+ "longRunningTask": "시간이 좀 걸릴 수 있습니다. Cloud로 어디서든 계속하세요.",
+ "taskList": "Roo Code Cloud가 여기 있습니다: 어디서든 작업을 추적하고 제어하세요. 더 알아보기 ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/nl/cloud.json b/webview-ui/src/i18n/locales/nl/cloud.json
index 00d6bb965e..533237c88c 100644
--- a/webview-ui/src/i18n/locales/nl/cloud.json
+++ b/webview-ui/src/i18n/locales/nl/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Problemen?",
"pasteCallbackUrl": "Kopieer de redirect-URL uit je browser en plak hem hier:",
"startOver": "Opnieuw beginnen",
- "cloudUrlPillLabel": "Roo Code Cloud URL"
+ "cloudUrlPillLabel": "Roo Code Cloud URL",
+ "upsell": {
+ "autoApprovePowerUser": "Roo wat onafhankelijkheid geven? Bedien het overal met Roo Code Cloud. Meer informatie .",
+ "longRunningTask": "Dit kan even duren. Ga overal verder met de Cloud.",
+ "taskList": "Roo Code Cloud is hier: volg en beheer je taken overal. Meer informatie ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/pl/cloud.json b/webview-ui/src/i18n/locales/pl/cloud.json
index 193397dc3b..be940d3710 100644
--- a/webview-ui/src/i18n/locales/pl/cloud.json
+++ b/webview-ui/src/i18n/locales/pl/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Masz problemy?",
"pasteCallbackUrl": "Skopiuj URL redirect z przeglądarki i wklej tutaj:",
"startOver": "Zacznij od nowa",
- "cloudUrlPillLabel": "URL Roo Code Cloud"
+ "cloudUrlPillLabel": "URL Roo Code Cloud",
+ "upsell": {
+ "autoApprovePowerUser": "Dać Roo trochę niezależności? Kontroluj go z dowolnego miejsca dzięki Roo Code Cloud. Dowiedz się więcej .",
+ "longRunningTask": "To może chwilę potrwać. Kontynuuj z dowolnego miejsca dzięki Chmurze.",
+ "taskList": "Roo Code Cloud jest tutaj: śledź i kontroluj swoje zadania z dowolnego miejsca. Dowiedz się więcej ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/pt-BR/cloud.json b/webview-ui/src/i18n/locales/pt-BR/cloud.json
index 0d542a56fb..8511982769 100644
--- a/webview-ui/src/i18n/locales/pt-BR/cloud.json
+++ b/webview-ui/src/i18n/locales/pt-BR/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Tendo problemas?",
"pasteCallbackUrl": "Copie a URL de redirect do seu navegador e cole aqui:",
"startOver": "Recomeçar",
- "cloudUrlPillLabel": "URL do Roo Code Cloud "
+ "cloudUrlPillLabel": "URL do Roo Code Cloud ",
+ "upsell": {
+ "autoApprovePowerUser": "Dando um pouco de independência ao Roo? Controle-o de qualquer lugar com o Roo Code Cloud. Saiba mais .",
+ "longRunningTask": "Isso pode levar um tempo. Continue de qualquer lugar com a Nuvem.",
+ "taskList": "O Roo Code Cloud está aqui: acompanhe e controle suas tarefas de qualquer lugar. Saiba mais ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/ru/cloud.json b/webview-ui/src/i18n/locales/ru/cloud.json
index e814b8ecad..b2a1aa85fe 100644
--- a/webview-ui/src/i18n/locales/ru/cloud.json
+++ b/webview-ui/src/i18n/locales/ru/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Проблемы?",
"pasteCallbackUrl": "Скопируй URL перенаправления из браузера и вставь его сюда:",
"startOver": "Начать заново",
- "cloudUrlPillLabel": "URL Roo Code Cloud"
+ "cloudUrlPillLabel": "URL Roo Code Cloud",
+ "upsell": {
+ "autoApprovePowerUser": "Предоставить Roo немного независимости? Управляйте им из любого места с помощью Roo Code Cloud. Узнать больше .",
+ "longRunningTask": "Это может занять некоторое время. Продолжайте из любого места с помощью Облака.",
+ "taskList": "Roo Code Cloud уже здесь: отслеживайте и управляйте своими задачами из любого места. Узнать больше ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/tr/cloud.json b/webview-ui/src/i18n/locales/tr/cloud.json
index 6f3a2bc881..e8630c2d9e 100644
--- a/webview-ui/src/i18n/locales/tr/cloud.json
+++ b/webview-ui/src/i18n/locales/tr/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "Sorun yaşıyor musun?",
"pasteCallbackUrl": "Tarayıcından redirect URL'sini kopyala ve buraya yapıştır:",
"startOver": "Baştan başla",
- "cloudUrlPillLabel": "Roo Code Cloud URL'si"
+ "cloudUrlPillLabel": "Roo Code Cloud URL'si",
+ "upsell": {
+ "autoApprovePowerUser": "Roo'ya biraz bağımsızlık mı veriyorsunuz? Roo Code Cloud ile onu her yerden kontrol edin. Daha fazla bilgi edinin .",
+ "longRunningTask": "Bu biraz zaman alabilir. Bulut ile her yerden devam edin.",
+ "taskList": "Roo Code Cloud burada: görevlerinizi her yerden takip edin ve kontrol edin. Daha fazla bilgi edinin ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/vi/cloud.json b/webview-ui/src/i18n/locales/vi/cloud.json
index 376727426a..069c57e87b 100644
--- a/webview-ui/src/i18n/locales/vi/cloud.json
+++ b/webview-ui/src/i18n/locales/vi/cloud.json
@@ -22,5 +22,10 @@
"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",
- "cloudUrlPillLabel": "URL Roo Code Cloud"
+ "cloudUrlPillLabel": "URL Roo Code Cloud",
+ "upsell": {
+ "autoApprovePowerUser": "Trao cho Roo một chút độc lập? Kiểm soát nó từ mọi nơi với Roo Code Cloud. Tìm hiểu thêm .",
+ "longRunningTask": "Việc này có thể mất một lúc. Tiếp tục từ mọi nơi với Cloud.",
+ "taskList": "Roo Code Cloud đã có mặt: theo dõi và kiểm soát các tác vụ của bạn từ mọi nơi. Tìm hiểu thêm ."
+ }
}
diff --git a/webview-ui/src/i18n/locales/zh-CN/cloud.json b/webview-ui/src/i18n/locales/zh-CN/cloud.json
index 98c816f20a..47006c9227 100644
--- a/webview-ui/src/i18n/locales/zh-CN/cloud.json
+++ b/webview-ui/src/i18n/locales/zh-CN/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "遇到问题?",
"pasteCallbackUrl": "从浏览器复制重定向 URL 并粘贴到这里:",
"startOver": "重新开始",
- "cloudUrlPillLabel": "Roo Code Cloud URL"
+ "cloudUrlPillLabel": "Roo Code Cloud URL",
+ "upsell": {
+ "autoApprovePowerUser": "给 Roo 一些独立性?使用 Roo Code Cloud 从任何地方控制它。 了解更多 。",
+ "longRunningTask": "这可能需要一段时间。使用 Cloud 从任何地方继续。",
+ "taskList": "Roo Code Cloud 在这里:从任何地方关注和控制您的任务。 了解更多 。"
+ }
}
diff --git a/webview-ui/src/i18n/locales/zh-TW/cloud.json b/webview-ui/src/i18n/locales/zh-TW/cloud.json
index 00e9f71621..053dd23024 100644
--- a/webview-ui/src/i18n/locales/zh-TW/cloud.json
+++ b/webview-ui/src/i18n/locales/zh-TW/cloud.json
@@ -22,5 +22,10 @@
"havingTrouble": "遇到問題?",
"pasteCallbackUrl": "從瀏覽器複製重新導向 URL 並貼上到這裡:",
"startOver": "重新開始",
- "cloudUrlPillLabel": "Roo Code Cloud URL"
+ "cloudUrlPillLabel": "Roo Code Cloud URL",
+ "upsell": {
+ "autoApprovePowerUser": "給 Roo 一點獨立性?使用 Roo Code Cloud 隨時隨地控制它。了解更多 。",
+ "longRunningTask": "這可能需要一些時間。使用雲端隨時隨地繼續。",
+ "taskList": "Roo Code Cloud 在此:隨時隨地追蹤和控制您的任務。了解更多 。"
+ }
}