diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3f3d2b4bdb..7e79855f7e 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 9a17cc4c94..a6e8e73a6a 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3004,5 +3004,39 @@ export const webviewMessageHandler = async ( break } + case "dismissUpsell": { + if (message.upsellId) { + 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) + } + + // 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)}`) + } + } + 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 0ee4361c3e..aaddc520cb 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 aee5510c47..93d0b9bc45 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -223,6 +223,8 @@ export interface WebviewMessage { | "queueMessage" | "removeQueuedMessage" | "editQueuedMessage" + | "dismissUpsell" + | "getDismissedUpsells" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -268,6 +270,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/chat/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index 8961fc7f5d..25b936eb74 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/webview-ui/src/components/chat/AutoApproveMenu.tsx @@ -9,6 +9,9 @@ import { AutoApproveToggle, AutoApproveSetting, autoApproveSettingsConfig } from import { StandardTooltip } from "@src/components/ui" import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState" import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles" +import DismissibleUpsell from "@src/components/common/DismissibleUpsell" +import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog" interface AutoApproveMenuProps { style?: React.CSSProperties @@ -35,7 +38,12 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { const { t } = useAppTranslation() + const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({ + autoOpenOnAuth: false, + }) + const baseToggles = useAutoApprovalToggles() + const enabledCount = useMemo(() => Object.values(baseToggles).filter(Boolean).length, [baseToggles]) // AutoApproveMenu needs alwaysApproveResubmit in addition to the base toggles const toggles = useMemo( @@ -173,6 +181,23 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { + + {enabledCount > 7 && ( + <> + openUpsell()} + dismissOnClick={false} + variant="banner"> + , + }} + /> + + + )} )} @@ -240,6 +265,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { /> + ) } 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/chat/ShareButton.tsx b/webview-ui/src/components/chat/ShareButton.tsx index 4bcabb3a1c..38fd7dda35 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" @@ -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,29 +29,34 @@ 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 [wasConnectInitiatedFromShare, setWasConnectInitiatedFromShare] = useState(false) const { t } = useTranslation() - const { sharingEnabled, cloudIsAuthenticated, cloudUserInfo } = useExtensionState() - const wasUnauthenticatedRef = useRef(false) - const initiatedAuthFromThisButtonRef = useRef(false) + 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) + setWasConnectInitiatedFromShare(false) + }, + }) - // Track authentication state changes to auto-open popover after login + // Auto-open popover when user becomes authenticated after clicking Connect from share button 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) - setConnectModalOpen(false) - setShareDropdownOpen(true) - initiatedAuthFromThisButtonRef.current = false // Reset the flag - } - wasUnauthenticatedRef.current = false + if (wasConnectInitiatedFromShare && cloudIsAuthenticated) { + setShareDropdownOpen(true) + setWasConnectInitiatedFromShare(false) } - }, [cloudIsAuthenticated, sharingEnabled]) + }, [wasConnectInitiatedFromShare, cloudIsAuthenticated]) // Listen for share success messages from the extension useEffect(() => { @@ -95,14 +98,9 @@ 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" }) + setWasConnectInitiatedFromShare(true) + handleConnect() setShareDropdownOpen(false) - setConnectModalOpen(false) } const handleShareButtonClick = () => { @@ -111,7 +109,8 @@ export const ShareButton = ({ item, disabled = false, showLabel = false }: Share if (!cloudIsAuthenticated) { // Show modal for unauthenticated users - setConnectModalOpen(true) + openUpsell() + telemetryClient.capture(TelemetryEventName.SHARE_CONNECT_TO_CLOUD_CLICKED) } else { // Show popover for authenticated users setShareDropdownOpen(true) @@ -241,43 +240,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/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 8fd06b168f..6164294722 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,11 +1,15 @@ -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" import type { ClineMessage } from "@roo-code/types" import { getModelMaxOutputTokens } from "@roo/api" +import { findLastIndex } from "@roo/array" import { formatLargeNumber } from "@src/utils/format" import { cn } from "@src/lib/utils" @@ -46,9 +50,37 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem } = useExtensionState() + const { apiConfiguration, currentTaskItem, clineMessages } = 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, + }) + + // Check if the task is complete by looking at the last relevant message (skipping resume messages) + const isTaskComplete = + clineMessages && clineMessages.length > 0 + ? (() => { + const lastRelevantIndex = findLastIndex( + clineMessages, + (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + ) + return lastRelevantIndex !== -1 + ? clineMessages[lastRelevantIndex]?.ask === "completion_result" + : false + })() + : false + + useEffect(() => { + const timer = setTimeout(() => { + if (currentTaskItem && !isTaskComplete) { + setShowLongRunningTaskMessage(true) + } + }, 120_000) // Show upsell after 2 minutes + + return () => clearTimeout(timer) + }, [currentTaskItem, isTaskComplete]) const textContainerRef = useRef(null) const textRef = useRef(null) @@ -69,6 +101,15 @@ const TaskHeader = ({ return (
+ {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)} + +
+ +
+
+
+
+ ) +} 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 ) : ( <> -
-
-
- Roo logo -
-
-
+
+
{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 && ( )} @@ -332,8 +295,8 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl {showManualEntry && ( // Manual URL entry form -
-

+

+

{t("cloud:pasteCallbackUrl")}

- +

+ or{" "} + +

)}
)} {cloudApiUrl && cloudApiUrl !== PRODUCTION_ROO_CODE_API_URL && ( -
-
+
+
+ {t("cloud:cloudUrlPillLabel")}: +
+ ) + }, +) + +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 ( -
- -
-

- {t("rooCloudCTA.title")} -
- {t("rooCloudCTA.description")} -

-

- {t("rooCloudCTA.joinWaitlist")} -

-
-
- ) -} - -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 在此:隨時隨地追蹤和控制您的任務。了解更多。" + } }