feat: In-extension dismissible upsells for Roo Code Cloud (#7850)

* First pass at separate upsell dialog

* Revert PR #7188 - Restore temperature parameter to fix TabbyApi/ExLlamaV2 crashes (#7594)

* fix: reduce CodeBlock button z-index to prevent overlap with popovers (#7783)

Fixes #7703 - CodeBlock language dropdown and copy button were appearing above popovers due to z-index: 100. Reduced to z-index: 40 to maintain proper layering hierarchy while keeping buttons functional.

* Make ollama models info transport work like lmstudio (#7679)

* feat: add click-to-edit, ESC-to-cancel, and fix padding consistency for chat messages (#7790)

* feat: add click-to-edit, ESC-to-cancel, and fix padding consistency

- Enable click-to-edit for past messages by making message text clickable
- Add ESC key handler to cancel edit mode in ChatTextArea
- Fix padding consistency between past and queued message editors
- Adjust right padding for edit mode to accommodate cancel button

Fixes #7788

* fix: adjust padding and layout for ChatTextArea in edit mode

* refactor: replace hardcoded pr-[72px] with standard Tailwind pr-20 class

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>

* Let people paste in the auth redirect url (#7805)

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
Co-authored-by: Bruno Bergher <me@brunobergher.com>

* fix: resolve chat message edit/delete duplication issues (#7793)

* fix: add GIT_EDITOR env var to merge-resolver mode for non-interactive rebase (#7819)

* UI: Render reasoning as plain italic (match <thinking>) (#7752)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>

* Add taskSyncEnabled to userSettingsConfigSchema (#7827)

feat: add taskSyncEnabled to userSettingsConfigSchema

Co-authored-by: Roo Code <roomote@roocode.com>

* Release: v1.75.0 (#7829)

chore: bump version to v1.75.0

* fix: prevent negative cost values and improve label visibility in evals chart (#7830)

Co-authored-by: Roo Code <roomote@roocode.com>

* Fix Groq context window display (#7839)

* feat: add DismissibleUpsell component for dismissible messages

- Created DismissibleUpsell component with variant support (banner/default)
- Added dismissedUpsells to GlobalState for persistence
- Implemented message handlers for dismissing and retrieving dismissed upsells
- Added comprehensive tests for the component
- Uses VSCode extension globalState for persistent storage

* fix: Apply PR feedback for DismissibleUpsell component

- Changed from className to separate 'id' and 'className' props for better semantics
- Added i18n support for accessibility labels (aria-label and title)
- Fixed memory leak by adding mounted flag to prevent state updates after unmount
- Fixed race condition by sending dismiss message before hiding component
- Fixed inefficient array operations in webviewMessageHandler
- Added comprehensive test coverage for edge cases including:
  - Multiple rapid dismissals
  - Component unmounting during async operations
  - Invalid/malformed message handling
  - Proper message sending before unmount
- Added null checks for message data to handle edge cases gracefully

* New Cloud upsell dialog in task share and cloud view, shared component

* Properly working DismissibleUpsell

* Working upsell for long-running tasks

* CTA in AutoApproveMenu

* Home page CTA

* Fixes the autoapprove upsell and some tests

* Visual and copy fixes

* Test fix

* Translations

* Stray className attribute

* Cloud view fixes in a left-aligned layout

* Removes unnecessary test

* Less flaky tests

* Fixes sharebutton behavior and updates associated tests

* Update webview-ui/src/i18n/locales/it/cloud.json

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Fix dismissed flicker

* Fix long task upsell

---------

Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
Co-authored-by: ItsOnlyBinary <ItsOnlyBinary@users.noreply.github.com>
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Bruno Bergher 2025-09-10 21:06:13 +01:00 • committed by GitHub
parent 80af39dc74
commit 469a70fe5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1490 additions and 240 deletions

View file

@ -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(),

View file

@ -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
}
}
}

View file

@ -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"

View file

@ -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

View file

@ -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) => {
</div>
<AutoApproveToggle {...toggles} onToggle={onAutoApproveToggle} />
{enabledCount > 7 && (
<>
<DismissibleUpsell
upsellId="autoApprovePowerUserA"
onClick={() => openUpsell()}
dismissOnClick={false}
variant="banner">
<Trans
i18nKey="cloud:upsell.autoApprovePowerUser"
components={{
learnMoreLink: <VSCodeLink href="#" />,
}}
/>
</DismissibleUpsell>
</>
)}
</div>
)}
@ -240,6 +265,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
/>
</div>
</div>
<CloudUpsellDialog open={isOpen} onOpenChange={closeUpsell} onConnect={handleConnect} />
</div>
)
}

View file

@ -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<ChatViewRef, ChatViewPro
clineAskRef.current = clineAsk
}, [clineAsk])
const {
isOpen: isUpsellOpen,
openUpsell,
closeUpsell,
handleConnect,
} = useCloudUpsell({
autoOpenOnAuth: false,
})
// Keep inputValueRef in sync with inputValue state
useEffect(() => {
inputValueRef.current = inputValue
@ -1831,7 +1843,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
{telemetrySetting === "unset" && <TelemetryBanner />}
<div className="mb-2.5">
{cloudIsAuthenticated || taskHistory.length < 4 ? <RooTips /> : <RooCloudCTA />}
{cloudIsAuthenticated || taskHistory.length < 4 ? (
<RooTips />
) : (
<>
<DismissibleUpsell
upsellId="taskList"
icon={<Cloud className="size-4 mt-0.5 shrink-0" />}
onClick={() => openUpsell()}
dismissOnClick={false}
className="bg-vscode-editor-background p-4 !text-base">
<Trans
i18nKey="cloud:upsell.taskList"
components={{
learnMoreLink: <VSCodeLink href="#" />,
}}
/>
</DismissibleUpsell>
</>
)}
</div>
{/* Show the task history preview if expanded and tasks exist */}
{taskHistory.length > 0 && isExpanded && <HistoryPreview />}
@ -2013,6 +2043,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
)}
<div id="roo-portal" />
<CloudUpsellDialog open={isUpsellOpen} onOpenChange={closeUpsell} onConnect={handleConnect} />
</div>
)
}

View file

@ -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()
// Track authentication state changes to auto-open popover after login
// 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)
},
})
// 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 */}
<Dialog open={connectModalOpen} onOpenChange={setConnectModalOpen}>
<DialogContent className="max-w-sm">
<DialogHeader className="text-center">
<DialogTitle className="text-lg font-medium text-vscode-foreground">
{t("cloud:cloudBenefitsTitle")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col space-y-6">
<div>
<p className="text-md text-vscode-descriptionForeground mb-4">
{t("cloud:cloudBenefitsSubtitle")}
</p>
<ul className="text-sm text-vscode-descriptionForeground space-y-2">
<li className="flex items-start">
<span className="mr-2 text-vscode-foreground">•</span>
{t("cloud:cloudBenefitSharing")}
</li>
<li className="flex items-start">
<span className="mr-2 text-vscode-foreground">•</span>
{t("cloud:cloudBenefitHistory")}
</li>
<li className="flex items-start">
<span className="mr-2 text-vscode-foreground">•</span>
{t("cloud:cloudBenefitMetrics")}
</li>
</ul>
</div>
<div className="flex flex-col gap-4">
<Button onClick={handleConnectToCloud} className="w-full">
{t("cloud:connect")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<CloudUpsellDialog open={connectModalOpen} onOpenChange={closeUpsell} onConnect={handleConnectToCloud} />
</>
)
}

View file

@ -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<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
@ -69,6 +101,15 @@ const TaskHeader = ({
return (
<div className="pt-2 pb-0 px-3">
{showLongRunningTaskMessage && !isTaskComplete && (
<DismissibleUpsell
upsellId="longRunningTask"
onClick={() => openUpsell()}
dismissOnClick={false}
variant="banner">
{t("cloud:upsell.longRunningTask")}
</DismissibleUpsell>
)}
<div
className={cn(
"px-2.5 pt-2.5 pb-2 flex flex-col gap-1.5 relative z-1 cursor-pointer",
@ -285,6 +326,7 @@ const TaskHeader = ({
)}
</div>
<TodoListDisplay todos={todos ?? (task as any)?.tool?.todos ?? []} />
<CloudUpsellDialog open={isOpen} onOpenChange={closeUpsell} onConnect={handleConnect} />
</div>
)
}

View file

@ -82,16 +82,10 @@ vi.mock("../Announcement", () => ({
},
}))
// Mock RooCloudCTA component
vi.mock("@src/components/welcome/RooCloudCTA", () => ({
default: function MockRooCloudCTA() {
return (
<div data-testid="roo-cloud-cta">
<div>rooCloudCTA.title</div>
<div>rooCloudCTA.description</div>
<div>rooCloudCTA.joinWaitlist</div>
</div>
)
// Mock DismissibleUpsell component
vi.mock("@/components/common/DismissibleUpsell", () => ({
default: function MockDismissibleUpsell({ children }: { children: React.ReactNode }) {
return <div data-testid="dismissible-upsell">{children}</div>
},
}))
@ -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()
})
})

View file

@ -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(<TaskActions item={mockItem} buttonsDisabled={true} />)
// 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(<TaskActions item={mockItem} buttonsDisabled={false} />)
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

View file

@ -32,18 +32,62 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeBadge: ({ children }: { children: React.ReactNode }) => <div data-testid="vscode-badge">{children}</div>,
}))
// 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) => (
<div data-testid="dismissible-upsell" {...props}>
{children}
</div>
),
}))
// 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()
})
})
})

View file

@ -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 (
<div className="text-left cursor-default">
<div className="w-15">
<RooHero />
</div>
<h1 className="text-xl font-bold text-vscode-foreground">{t("cloud:cloudBenefitsTitle")}</h1>
<div className="text-lg">
<ul className="text-vscode-descriptionForeground space-y-4 my-8">
<li className="flex items-start gap-2">
<Router className="size-4 mt-0.5 shrink-0" />
{t("cloud:cloudBenefitWalkaway")}
</li>
<li className="flex items-start gap-2">
<Share className="size-4 mt-0.5 shrink-0" />
{t("cloud:cloudBenefitSharing")}
</li>
<li className="flex items-start gap-2">
<CircleDollarSign className="size-4 mt-0.5 shrink-0" />
{t("cloud:cloudBenefitMetrics")}
</li>
<li className="flex items-start gap-2">
<FileStack className="size-4 mt-0.5 shrink-0" />
{t("cloud:cloudBenefitHistory")}
</li>
</ul>
</div>
</div>
)
}
export const CloudUpsellDialog = ({ open, onOpenChange, onConnect }: CloudUpsellDialogProps) => {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{/* Intentionally empty */}</DialogTitle>
</DialogHeader>
<div className="text-left space-y-6">
{renderCloudBenefitsContent(t)}
<div className="flex flex-col gap-4">
<Button onClick={onConnect} className="w-full">
{t("cloud:connect")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -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<NodeJS.Timeout | null>(null)
const manualUrlInputRef = useRef<HTMLInputElement | null>(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 (
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-medium text-vscode-foreground">{t("cloud:title")}</h1>
<h1 className="text-xl font-medium text-vscode-foreground">{isAuthenticated && t("cloud:title")}</h1>
<VSCodeButton appearance="secondary" onClick={onDone}>
{t("settings:common.done")}
</VSCodeButton>
@ -268,46 +266,11 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
</>
) : (
<>
<div className="flex flex-col items-center mb-1 text-center">
<div className="w-16 h-16 mb-1 flex items-center justify-center">
<div
className="w-12 h-12 bg-vscode-foreground"
style={{
WebkitMaskImage: `url('${rooLogoUri}')`,
WebkitMaskRepeat: "no-repeat",
WebkitMaskSize: "contain",
maskImage: `url('${rooLogoUri}')`,
maskRepeat: "no-repeat",
maskSize: "contain",
}}>
<img src={rooLogoUri} alt="Roo logo" className="w-12 h-12 opacity-0" />
</div>
</div>
</div>
<div className="flex flex-col items-start gap-4 px-8">
<div className={cn(authInProgress && "opacity-50")}>{renderCloudBenefitsContent(t)}</div>
<div className="flex flex-col mb-6 text-center">
<h2 className="text-xl font-bold text-vscode-foreground mb-2">
{t("cloud:cloudBenefitsTitle")}
</h2>
<ul className="text-vscode-descriptionForeground space-y-3 mx-auto px-8">
<li className="flex items-start text-left gap-4">
<SquareArrowOutUpRightIcon size="16" className="shrink-0" />
{t("cloud:cloudBenefitSharing")}
</li>
<li className="flex items-start text-left gap-4">
<History size="16" className="shrink-0" />
{t("cloud:cloudBenefitHistory")}
</li>
<li className="flex items-start text-left gap-4">
<PiggyBank size="16" className="shrink-0" />
{t("cloud:cloudBenefitMetrics")}
</li>
</ul>
</div>
<div className="flex flex-col items-center gap-4">
{!authInProgress && (
<VSCodeButton appearance="primary" onClick={handleConnectClick} className="w-1/2">
<VSCodeButton appearance="primary" onClick={handleConnectClick} className="w-full">
{t("cloud:connect")}
</VSCodeButton>
)}
@ -315,15 +278,15 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
{/* Manual entry section */}
{authInProgress && !showManualEntry && (
// Timeout message with "Having trouble?" link
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2 text-sm text-vscode-descriptionForeground">
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-2 text-base text-vscode-descriptionForeground">
<VSCodeProgressRing className="size-3 text-vscode-foreground" />
{t("cloud:authWaiting")}
</div>
{!showManualEntry && (
<button
onClick={handleShowManualEntry}
className="text-sm text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground underline cursor-pointer bg-transparent border-none p-0">
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")}
</button>
)}
@ -332,8 +295,8 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
{showManualEntry && (
// Manual URL entry form
<div className="space-y-2 text-center border-border/50 border-t px-2 pt-8 mt-3">
<p className="text-xs text-vscode-descriptionForeground">
<div className="space-y-2 max-w-72">
<p className="text-base text-vscode-descriptionForeground">
{t("cloud:pasteCallbackUrl")}
</p>
<VSCodeTextField
@ -344,19 +307,23 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
placeholder="vscode://RooVeterinaryInc.roo-cline/auth/clerk/callback?state=..."
className="w-full"
/>
<button
onClick={handleReset}
className="text-sm text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground underline cursor-pointer bg-transparent border-none p-0">
{t("cloud:startOver")}
</button>
<p className="mt-1">
or{" "}
<button
onClick={handleReset}
className="text-base text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground underline cursor-pointer bg-transparent border-none p-0">
{t("cloud:startOver")}
</button>
</p>
</div>
)}
</div>
</>
)}
{cloudApiUrl && cloudApiUrl !== PRODUCTION_ROO_CODE_API_URL && (
<div className="mt-6 flex justify-center">
<div className="inline-flex items-center px-3 py-1 gap-1 rounded-full bg-vscode-badge-background/50 text-vscode-badge-foreground text-xs">
<div className="ml-8 mt-6 flex justify-start">
<div className="inline-flex items-center gap-2 text-xs">
<TriangleAlert className="size-4 text-vscode-descriptionForeground" />
<span className="text-vscode-foreground/75">{t("cloud:cloudUrlPillLabel")}: </span>
<button
onClick={handleOpenCloudUrl}

View file

@ -0,0 +1,61 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { vi } from "vitest"
import { CloudUpsellDialog } from "../CloudUpsellDialog"
// Mock the useTranslation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"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(<CloudUpsellDialog open={true} onOpenChange={mockOnOpenChange} onConnect={mockOnConnect} />)
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(<CloudUpsellDialog open={false} onOpenChange={mockOnOpenChange} onConnect={mockOnConnect} />)
expect(screen.queryByText("Connect to Roo Cloud")).not.toBeInTheDocument()
})
it("calls onConnect when connect button is clicked", () => {
render(<CloudUpsellDialog open={true} onOpenChange={mockOnOpenChange} onConnect={mockOnConnect} />)
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(<CloudUpsellDialog open={true} onOpenChange={mockOnOpenChange} onConnect={mockOnConnect} />)
const listItems = screen.getAllByRole("listitem")
expect(listItems).toHaveLength(4)
})
})

View file

@ -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 = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.647 3.646.708.707L8 8.707z"
fill="currentColor"
/>
</svg>
)
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 (
<div
className={containerClasses}
onClick={() => {
// Call the onClick handler if provided
onClick?.()
// Also dismiss if dismissOnClick is true
if (dismissOnClick) {
handleDismiss()
}
}}>
{icon && icon}
<div>{children}</div>
<button
className={buttonClasses}
onClick={(e) => {
e.stopPropagation() // Prevent triggering the container's onClick
handleDismiss()
}}
aria-label={t("common:dismiss")}
title={t("common:dismissAndDontShowAgain")}>
<DismissIcon />
</button>
</div>
)
},
)
DismissibleUpsell.displayName = "DismissibleUpsell"
export default DismissibleUpsell

View file

@ -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<string, string> = {
"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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
expect(mockPostMessage).toHaveBeenCalledWith({
type: "getDismissedUpsells",
})
})
it("hides the upsell when dismiss button is clicked", async () => {
const onDismiss = vi.fn()
const { container } = render(
<DismissibleUpsell upsellId="test-upsell" onDismiss={onDismiss}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" className="custom-class">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onDismiss={onDismiss}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="unique-id" className="styling-class">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onClick={onClick}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onClick={onClick} onDismiss={onDismiss}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onClick={() => {}}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onClick={onClick} onDismiss={onDismiss}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onClick={onClick} onDismiss={onDismiss} dismissOnClick={true}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onClick={onClick} onDismiss={onDismiss} dismissOnClick={false}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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(
<DismissibleUpsell upsellId="test-upsell" onClick={onClick} onDismiss={onDismiss}>
<div>Test content</div>
</DismissibleUpsell>,
)
// 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()
})
})

View file

@ -1,23 +0,0 @@
import { useTranslation } from "react-i18next"
export function RooCloudCTA() {
const { t } = useTranslation("chat")
return (
<div className="border border-muted/20 px-4 py-1 text-center flex items-start gap-2">
<i className="mr-1 codicon codicon-cloud text-xl! mt-2 text-vscode-descriptionForeground" />
<div className="text-left">
<p>
<strong>{t("rooCloudCTA.title")}</strong>
<br />
<span>{t("rooCloudCTA.description")}</span>
</p>
<p>
<a href="https://roocode.com/cloud-waitlist">{t("rooCloudCTA.joinWaitlist")}</a>
</p>
</div>
</div>
)
}
export default RooCloudCTA

View file

@ -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,
}
}

View file

@ -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. <learnMoreLink>Més informació</learnMoreLink>.",
"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. <learnMoreLink>Més informació</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Mehr erfahren</learnMoreLink>.",
"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. <learnMoreLink>Mehr erfahren</learnMoreLink>."
}
}

View file

@ -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"
},

View file

@ -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. <learnMoreLink>Learn more</learnMoreLink>.",
"longRunningTask": "This might take a while. Continue from anywhere with Cloud.",
"taskList": "Roo Code Cloud is here: follow and control your tasks from anywhere. <learnMoreLink>Learn more</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Saber más</learnMoreLink>.",
"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. <learnMoreLink>Saber más</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>En savoir plus</learnMoreLink>.",
"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ù. <learnMoreLink>En savoir plus</learnMoreLink>."
}
}

View file

@ -22,5 +22,10 @@
"havingTrouble": "समस्या हो रही है?",
"pasteCallbackUrl": "अपने ब्राउज़र से redirect URL कॉपी करें और यहाँ पेस्ट करें:",
"startOver": "फिर से शुरू करें",
"cloudUrlPillLabel": "Roo Code Cloud URL"
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "रू को थोड़ी स्वतंत्रता दे रहे हैं? रू कोड क्लाउड के साथ इसे कहीं से भी नियंत्रित करें। <learnMoreLink>और जानें</learnMoreLink>।",
"longRunningTask": "इसमें थोड़ा समय लग सकता है। क्लाउड के साथ कहीं से भी जारी रखें।",
"taskList": "रू कोड क्लाउड यहाँ है: कहीं से भी अपने कार्यों का पालन और नियंत्रण करें। <learnMoreLink>और जानें</learnMoreLink>।"
}
}

View file

@ -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. <learnMoreLink>Pelajari lebih lanjut</learnMoreLink>.",
"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. <learnMoreLink>Pelajari lebih lanjut</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Scopri di più</learnMoreLink>.",
"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. <learnMoreLink>Scopri di più</learnMoreLink>."
}
}

View file

@ -22,5 +22,10 @@
"havingTrouble": "問題が発生していますか?",
"pasteCallbackUrl": "ブラウザからリダイレクトURLをコピーし、ここに貼り付けてください:",
"startOver": "最初からやり直す",
"cloudUrlPillLabel": "Roo Code Cloud URL"
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "Rooに少し独立性を与えませんか?Roo Code Cloudでどこからでもコントロールできます。<learnMoreLink>詳細</learnMoreLink>。",
"longRunningTask": "これには時間がかかるかもしれません。Cloudを使えばどこからでも続けられます。",
"taskList": "Roo Code Cloudが登場しました:どこからでもタスクを追跡し、コントロールできます。<learnMoreLink>詳細</learnMoreLink>。"
}
}

View file

@ -22,5 +22,10 @@
"havingTrouble": "문제가 있나요?",
"pasteCallbackUrl": "브라우저에서 리다이렉트 URL을 복사하여 여기에 붙여넣으세요:",
"startOver": "다시 시작",
"cloudUrlPillLabel": "Roo Code Cloud URL"
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "Roo에게 약간의 독립성을 부여하시겠습니까? Roo Code Cloud로 어디서든 제어하세요. <learnMoreLink>더 알아보기</learnMoreLink>.",
"longRunningTask": "시간이 좀 걸릴 수 있습니다. Cloud로 어디서든 계속하세요.",
"taskList": "Roo Code Cloud가 여기 있습니다: 어디서든 작업을 추적하고 제어하세요. <learnMoreLink>더 알아보기</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Meer informatie</learnMoreLink>.",
"longRunningTask": "Dit kan even duren. Ga overal verder met de Cloud.",
"taskList": "Roo Code Cloud is hier: volg en beheer je taken overal. <learnMoreLink>Meer informatie</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Dowiedz się więcej</learnMoreLink>.",
"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. <learnMoreLink>Dowiedz się więcej</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Saiba mais</learnMoreLink>.",
"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. <learnMoreLink>Saiba mais</learnMoreLink>."
}
}

View file

@ -22,5 +22,10 @@
"havingTrouble": "Проблемы?",
"pasteCallbackUrl": "Скопируй URL перенаправления из браузера и вставь его сюда:",
"startOver": "Начать заново",
"cloudUrlPillLabel": "URL Roo Code Cloud"
"cloudUrlPillLabel": "URL Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Предоставить Roo немного независимости? Управляйте им из любого места с помощью Roo Code Cloud. <learnMoreLink>Узнать больше</learnMoreLink>.",
"longRunningTask": "Это может занять некоторое время. Продолжайте из любого места с помощью Облака.",
"taskList": "Roo Code Cloud уже здесь: отслеживайте и управляйте своими задачами из любого места. <learnMoreLink>Узнать больше</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Daha fazla bilgi edinin</learnMoreLink>.",
"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. <learnMoreLink>Daha fazla bilgi edinin</learnMoreLink>."
}
}

View file

@ -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. <learnMoreLink>Tìm hiểu thêm</learnMoreLink>.",
"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. <learnMoreLink>Tìm hiểu thêm</learnMoreLink>."
}
}

View file

@ -22,5 +22,10 @@
"havingTrouble": "遇到问题?",
"pasteCallbackUrl": "从浏览器复制重定向 URL 并粘贴到这里:",
"startOver": "重新开始",
"cloudUrlPillLabel": "Roo Code Cloud URL"
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "给 Roo 一些独立性?使用 Roo Code Cloud 从任何地方控制它。 <learnMoreLink>了解更多</learnMoreLink>。",
"longRunningTask": "这可能需要一段时间。使用 Cloud 从任何地方继续。",
"taskList": "Roo Code Cloud 在这里:从任何地方关注和控制您的任务。 <learnMoreLink>了解更多</learnMoreLink>。"
}
}

View file

@ -22,5 +22,10 @@
"havingTrouble": "遇到問題?",
"pasteCallbackUrl": "從瀏覽器複製重新導向 URL 並貼上到這裡:",
"startOver": "重新開始",
"cloudUrlPillLabel": "Roo Code Cloud URL"
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "給 Roo 一點獨立性?使用 Roo Code Cloud 隨時隨地控制它。<learnMoreLink>了解更多</learnMoreLink>。",
"longRunningTask": "這可能需要一些時間。使用雲端隨時隨地繼續。",
"taskList": "Roo Code Cloud 在此:隨時隨地追蹤和控制您的任務。<learnMoreLink>了解更多</learnMoreLink>。"
}
}