mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-10 03:28:03 +00:00
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:
parent
80af39dc74
commit
469a70fe5d
37 changed files with 1490 additions and 240 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
67
webview-ui/src/components/cloud/CloudUpsellDialog.tsx
Normal file
67
webview-ui/src/components/cloud/CloudUpsellDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
163
webview-ui/src/components/common/DismissibleUpsell.tsx
Normal file
163
webview-ui/src/components/common/DismissibleUpsell.tsx
Normal 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
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
71
webview-ui/src/hooks/useCloudUpsell.ts
Normal file
71
webview-ui/src/hooks/useCloudUpsell.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
7
webview-ui/src/i18n/locales/ca/cloud.json
generated
7
webview-ui/src/i18n/locales/ca/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/de/cloud.json
generated
7
webview-ui/src/i18n/locales/de/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/es/cloud.json
generated
7
webview-ui/src/i18n/locales/es/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/fr/cloud.json
generated
7
webview-ui/src/i18n/locales/fr/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/hi/cloud.json
generated
7
webview-ui/src/i18n/locales/hi/cloud.json
generated
|
|
@ -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>।"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/id/cloud.json
generated
7
webview-ui/src/i18n/locales/id/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/it/cloud.json
generated
7
webview-ui/src/i18n/locales/it/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/ja/cloud.json
generated
7
webview-ui/src/i18n/locales/ja/cloud.json
generated
|
|
@ -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>。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/ko/cloud.json
generated
7
webview-ui/src/i18n/locales/ko/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/nl/cloud.json
generated
7
webview-ui/src/i18n/locales/nl/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/pl/cloud.json
generated
7
webview-ui/src/i18n/locales/pl/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/pt-BR/cloud.json
generated
7
webview-ui/src/i18n/locales/pt-BR/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/ru/cloud.json
generated
7
webview-ui/src/i18n/locales/ru/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/tr/cloud.json
generated
7
webview-ui/src/i18n/locales/tr/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/vi/cloud.json
generated
7
webview-ui/src/i18n/locales/vi/cloud.json
generated
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/zh-CN/cloud.json
generated
7
webview-ui/src/i18n/locales/zh-CN/cloud.json
generated
|
|
@ -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>。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
webview-ui/src/i18n/locales/zh-TW/cloud.json
generated
7
webview-ui/src/i18n/locales/zh-TW/cloud.json
generated
|
|
@ -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>。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue