Random chat text area cleanup (#7436)

This commit is contained in:
Chris Estreich 2025-08-26 20:48:24 -07:00 committed by GitHub
parent 3528f51304
commit f02a2bb05d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 834 additions and 1163 deletions

View file

@ -24,7 +24,7 @@
"@radix-ui/react-slider": "^1.2.4",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@roo-code/evals": "workspace:^",
"@roo-code/types": "workspace:^",
"@tanstack/react-query": "^5.69.0",

166
pnpm-lock.yaml generated
View file

@ -161,8 +161,8 @@ importers:
specifier: ^1.1.3
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.1.8
version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@roo-code/evals':
specifier: workspace:^
version: link:../../packages/evals
@ -2575,6 +2575,9 @@ packages:
'@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-alert-dialog@1.1.13':
resolution: {integrity: sha512-/uPs78OwxGxslYOG5TKeUsv9fZC0vo376cXSADdKirTmsLJU2au6L3n34c3p6W26rFDDDze/hwy4fYeNd0qdGA==}
peerDependencies:
@ -2601,6 +2604,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.1':
resolution: {integrity: sha512-xTaLKAO+XXMPK/BpVTSaAAhlefmvMSACjIhK9mGsImvX2ljcTDm8VGR1CuS1uYcNdR5J+oiOhoJZc5un6bh3VQ==}
peerDependencies:
@ -2719,6 +2735,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.9':
resolution: {integrity: sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ==}
peerDependencies:
@ -2846,6 +2875,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.8':
resolution: {integrity: sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg==}
peerDependencies:
@ -2885,6 +2927,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.2':
resolution: {integrity: sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==}
peerDependencies:
@ -3046,6 +3101,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@ -3131,6 +3199,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@ -11905,6 +11986,8 @@ snapshots:
'@radix-ui/primitive@1.1.2': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-alert-dialog@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -11928,6 +12011,15 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-checkbox@1.3.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -12059,6 +12151,19 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-dismissable-layer@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -12202,6 +12307,24 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/rect': 1.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-portal@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.2(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -12232,6 +12355,16 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-primitive@2.1.2(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.2(@types/react@18.3.23)(react@18.3.1)
@ -12418,6 +12551,26 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)':
dependencies:
react: 18.3.1
@ -12481,6 +12634,15 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/rect@1.1.1': {}
'@redis/bloom@5.5.5(@redis/client@5.5.5)':

View file

@ -4,13 +4,12 @@ import * as os from "os"
import * as fs from "fs/promises"
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import * as yaml from "yaml"
import {
type Language,
type ProviderSettings,
type GlobalState,
type ClineMessage,
type TelemetrySetting,
TelemetryEventName,
} from "@roo-code/types"
import { CloudService } from "@roo-code/cloud"
@ -21,7 +20,6 @@ import { ClineProvider } from "./ClineProvider"
import { changeLanguage, t } from "../../i18n"
import { Package } from "../../shared/package"
import { RouterName, toRouterName, ModelRecord } from "../../shared/api"
import { supportPrompt } from "../../shared/support-prompt"
import { MessageEnhancer } from "./messageEnhancer"
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
@ -29,7 +27,6 @@ import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { CodeIndexManager } from "../../services/code-index/manager"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
@ -42,9 +39,7 @@ import { exportSettings, importSettingsWithFeedback } from "../config/importExpo
import { getOpenAiModels } from "../../api/providers/openai"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
import { openMention } from "../mentions"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { getWorkspacePath } from "../../utils/path"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { GetModelsOptions } from "../../shared/api"

View file

@ -1 +0,0 @@
export type TelemetrySetting = "unset" | "enabled" | "disabled"

View file

@ -1,4 +1,5 @@
import { ClineMessage } from "@roo-code/types"
import type { ClineMessage } from "@roo-code/types"
import { safeJsonParse } from "./safeJsonParse"
export const COMMAND_OUTPUT_STRING = "Output:"

View file

@ -1,18 +1,21 @@
import React, { useState, useMemo, useCallback } from "react"
import { useState, useMemo, useCallback } from "react"
import { Fzf } from "fzf"
import { ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { IconButton } from "./IconButton"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
import { Fzf } from "fzf"
import { Button } from "@/components/ui"
import { IconButton } from "./IconButton"
interface ApiConfigSelectorProps {
value: string
displayName: string
disabled?: boolean
title?: string
title: string
onChange: (value: string) => void
triggerClassName?: string
listApiConfigMeta: Array<{ id: string; name: string }>
@ -24,7 +27,7 @@ export const ApiConfigSelector = ({
value,
displayName,
disabled = false,
title = "",
title,
onChange,
triggerClassName = "",
listApiConfigMeta,
@ -36,30 +39,33 @@ export const ApiConfigSelector = ({
const [searchValue, setSearchValue] = useState("")
const portalContainer = useRooPortal("roo-portal")
// Create searchable items for fuzzy search
const searchableItems = useMemo(() => {
return listApiConfigMeta.map((config) => ({
original: config,
searchStr: config.name,
}))
}, [listApiConfigMeta])
// Create searchable items for fuzzy search.
const searchableItems = useMemo(
() =>
listApiConfigMeta.map((config) => ({
original: config,
searchStr: config.name,
})),
[listApiConfigMeta],
)
// Create Fzf instance
const fzfInstance = useMemo(() => {
return new Fzf(searchableItems, {
selector: (item) => item.searchStr,
})
}, [searchableItems])
// Create Fzf instance.
const fzfInstance = useMemo(
() => new Fzf(searchableItems, { selector: (item) => item.searchStr }),
[searchableItems],
)
// Filter configs based on search
// Filter configs based on search.
const filteredConfigs = useMemo(() => {
if (!searchValue) return listApiConfigMeta
if (!searchValue) {
return listApiConfigMeta
}
const matchingItems = fzfInstance.find(searchValue).map((result) => result.item.original)
return matchingItems
}, [listApiConfigMeta, searchValue, fzfInstance])
// Separate pinned and unpinned configs
// Separate pinned and unpinned configs.
const { pinnedConfigs, unpinnedConfigs } = useMemo(() => {
const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id])
const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
@ -76,10 +82,7 @@ export const ApiConfigSelector = ({
)
const handleEditClick = useCallback(() => {
vscode.postMessage({
type: "switchTab",
tab: "settings",
})
vscode.postMessage({ type: "switchTab", tab: "settings" })
setOpen(false)
}, [])
@ -112,10 +115,7 @@ export const ApiConfigSelector = ({
onClick={(e) => {
e.stopPropagation()
togglePinnedApiConfig(config.id)
vscode.postMessage({
type: "toggleApiConfigPin",
text: config.id,
})
vscode.postMessage({ type: "toggleApiConfigPin", text: config.id })
}}
className={cn("size-5 flex items-center justify-center", {
"opacity-0 group-hover:opacity-100": !isPinned && !isCurrentConfig,
@ -131,32 +131,30 @@ export const ApiConfigSelector = ({
[value, handleSelect, t, togglePinnedApiConfig],
)
const triggerContent = (
<PopoverTrigger
disabled={disabled}
data-testid="dropdown-trigger"
className={cn(
"w-full min-w-0 max-w-full inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
)}>
<span
className={cn(
"codicon codicon-chevron-up pointer-events-none opacity-80 flex-shrink-0 text-xs transition-transform duration-200",
open && "rotate-180",
)}
/>
<span className="truncate">{displayName}</span>
</PopoverTrigger>
)
return (
<Popover open={open} onOpenChange={setOpen}>
{title ? <StandardTooltip content={title}>{triggerContent}</StandardTooltip> : triggerContent}
<Popover open={open} onOpenChange={setOpen} data-testid="api-config-selector-root">
<StandardTooltip content={title}>
<PopoverTrigger
disabled={disabled}
data-testid="dropdown-trigger"
className={cn(
"w-full min-w-0 max-w-full inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
)}>
<ChevronUp
className={cn(
"pointer-events-none opacity-80 flex-shrink-0 size-3 transition-transform duration-200",
open && "rotate-180",
)}
/>
<span className="truncate">{displayName}</span>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
align="start"
sideOffset={4}
@ -214,12 +212,13 @@ export const ApiConfigSelector = ({
</div>
{/* Bottom bar with buttons on left and title on right */}
<div className="flex flex-row items-center justify-between p-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:edit")}
onClick={handleEditClick}
tooltip={false}
/>
</div>

View file

@ -1,18 +1,14 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { appendImages } from "@src/utils/imageUtils"
import { McpExecution } from "./McpExecution"
import { useSize } from "react-use"
import { useTranslation, Trans } from "react-i18next"
import deepEqual from "fast-deep-equal"
import { VSCodeBadge, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import type { ClineMessage } from "@roo-code/types"
import { Mode } from "@roo/modes"
import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types"
import { ClineApiReqInfo, ClineAskUseMcpServer, ClineSayTool } from "@roo/ExtensionMessage"
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
import { safeJsonParse } from "@roo/safeJsonParse"
import { FollowUpData, SuggestionItem } from "@roo-code/types"
import { useCopyToClipboard } from "@src/utils/clipboard"
import { useExtensionState } from "@src/context/ExtensionStateContext"
@ -22,9 +18,6 @@ import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanu
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
import { Button } from "@src/components/ui"
import ChatTextArea from "./ChatTextArea"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
import CodeAccordian from "../common/CodeAccordian"
@ -32,6 +25,7 @@ import CodeBlock from "../common/CodeBlock"
import MarkdownBlock from "../common/MarkdownBlock"
import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import { Mention } from "./Mention"
@ -46,6 +40,7 @@ import { CommandExecutionError } from "./CommandExecutionError"
import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning"
import { CondenseContextErrorRow, CondensingContextRow, ContextCondenseRow } from "./ContextCondenseRow"
import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay"
import { McpExecution } from "./McpExecution"
interface ChatRowProps {
message: ClineMessage
@ -114,69 +109,20 @@ export const ChatRowContent = ({
editable,
}: ChatRowContentProps) => {
const { t } = useTranslation()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode } = useExtensionState()
const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState()
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState("")
const [editMode, setEditMode] = useState<Mode>(mode || "code")
const [editImages, setEditImages] = useState<string[]>([])
const { copyWithFeedback } = useCopyToClipboard()
// Handle message events for image selection during edit mode
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const msg = event.data
if (msg.type === "selectedImages" && msg.context === "edit" && msg.messageTs === message.ts && isEditing) {
setEditImages((prevImages) => appendImages(prevImages, msg.images, MAX_IMAGES_PER_MESSAGE))
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [isEditing, message.ts])
// Memoized callback to prevent re-renders caused by inline arrow functions
// Memoized callback to prevent re-renders caused by inline arrow functions.
const handleToggleExpand = useCallback(() => {
onToggleExpand(message.ts)
}, [onToggleExpand, message.ts])
// Handle edit button click
const handleEditClick = useCallback(() => {
setIsEditing(true)
setEditedContent(message.text || "")
setEditImages(message.images || [])
setEditMode(mode || "code")
// Edit mode is now handled entirely in the frontend
// No need to notify the backend
}, [message.text, message.images, mode])
// Handle cancel edit
const handleCancelEdit = useCallback(() => {
setIsEditing(false)
setEditedContent(message.text || "")
setEditImages(message.images || [])
setEditMode(mode || "code")
}, [message.text, message.images, mode])
// Handle save edit
const handleSaveEdit = useCallback(() => {
setIsEditing(false)
// Send edited message to backend
vscode.postMessage({
type: "submitEditedMessage",
value: message.ts,
editedMessageContent: editedContent,
images: editImages,
})
}, [message.ts, editedContent, editImages])
// Handle image selection for editing
const handleSelectImages = useCallback(() => {
vscode.postMessage({ type: "selectImages", context: "edit", messageTs: message.ts })
}, [message.ts])
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
const info = safeJsonParse<ClineApiReqInfo>(message.text)
@ -1061,58 +1007,26 @@ export const ChatRowContent = ({
case "user_feedback":
return (
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap">
{isEditing ? (
<div className="flex flex-col gap-2 p-2">
<ChatTextArea
inputValue={editedContent}
setInputValue={setEditedContent}
sendingDisabled={false}
selectApiConfigDisabled={true}
placeholderText={t("chat:editMessage.placeholder")}
selectedImages={editImages}
setSelectedImages={setEditImages}
onSend={handleSaveEdit}
onSelectImages={handleSelectImages}
shouldDisableImages={false}
mode={editMode}
setMode={setEditMode}
modeShortcutText=""
isEditMode={true}
onCancel={handleCancelEdit}
/>
<div className="flex justify-between">
<div className="flex-grow px-2 py-1 wrap-anywhere">
<Mention text={message.text} withShadow />
</div>
) : (
<div className="flex justify-between">
<div className="flex-grow px-2 py-1 wrap-anywhere">
<Mention text={message.text} withShadow />
</div>
<div className="flex">
<Button
variant="ghost"
size="icon"
className="shrink-0 hidden"
disabled={isStreaming}
onClick={(e) => {
e.stopPropagation()
handleEditClick()
}}>
<span className="codicon codicon-edit" />
</Button>
<Button
variant="ghost"
size="icon"
className="shrink-0"
disabled={isStreaming}
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "deleteMessage", value: message.ts })
}}>
<span className="codicon codicon-trash" />
</Button>
</div>
<div className="flex">
<Button
variant="ghost"
size="icon"
className="shrink-0"
disabled={isStreaming}
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "deleteMessage", value: message.ts })
}}>
<span className="codicon codicon-trash" />
</Button>
</div>
)}
{!isEditing && message.images && message.images.length > 0 && (
</div>
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
</div>

View file

@ -1,15 +1,16 @@
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import DynamicTextArea from "react-textarea-autosize"
import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
import { WebviewMessage } from "@roo/WebviewMessage"
import { Mode, getAllModes } from "@roo/modes"
import { ExtensionMessage } from "@roo/ExtensionMessage"
import { vscode } from "@/utils/vscode"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import {
ContextMenuOptionType,
getContextMenuOptions,
@ -18,20 +19,18 @@ import {
shouldShowContextMenu,
SearchResult,
} from "@src/utils/context-mentions"
import { convertToMentionPath } from "@/utils/path-mentions"
import { StandardTooltip } from "@/components/ui"
import { cn } from "@src/lib/utils"
import { convertToMentionPath } from "@src/utils/path-mentions"
import { StandardTooltip } from "@src/components/ui"
import Thumbnails from "../common/Thumbnails"
import ModeSelector from "./ModeSelector"
import { ModeSelector } from "./ModeSelector"
import { ApiConfigSelector } from "./ApiConfigSelector"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { SlashCommandsPopover } from "./SlashCommandsPopover"
import { cn } from "@/lib/utils"
import { usePromptHistory } from "./hooks/usePromptHistory"
import { EditModeControls } from "./EditModeControls"
interface ChatTextAreaProps {
inputValue: string
@ -48,17 +47,13 @@ interface ChatTextAreaProps {
mode: Mode
setMode: (value: Mode) => void
modeShortcutText: string
// Edit mode props
isEditMode?: boolean
onCancel?: () => void
}
const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
(
{
inputValue,
setInputValue,
sendingDisabled,
selectApiConfigDisabled,
placeholderText,
selectedImages,
@ -70,8 +65,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
mode,
setMode,
modeShortcutText,
isEditMode = false,
onCancel,
},
ref,
) => {
@ -91,12 +84,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
commands,
} = useExtensionState()
// Find the ID and display text for the currently selected API configuration
// Find the ID and display text for the currently selected API configuration.
const { currentConfigId, displayName } = useMemo(() => {
const currentConfig = listApiConfigMeta?.find((config) => config.name === currentApiConfigName)
return {
currentConfigId: currentConfig?.id || "",
displayName: currentApiConfigName || "", // Use the name directly for display
displayName: currentApiConfigName || "", // Use the name directly for display.
}
}, [listApiConfigMeta, currentApiConfigName])
@ -888,7 +881,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
// Common mode selector handler
const handleModeChange = useCallback(
(value: Mode) => {
setMode(value)
@ -897,261 +889,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[setMode],
)
// Helper function to render mode selector
const renderModeSelector = () => (
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={handleModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
)
// Helper function to handle API config change
const handleApiConfigChange = useCallback((value: string) => {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
}, [])
// Helper function to render non-edit mode controls
const renderNonEditModeControls = () => (
<div className={cn("flex", "justify-between", "items-center", "mt-auto")}>
<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
<div className="shrink-0">{renderModeSelector()}</div>
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
<ApiConfigSelector
value={currentConfigId}
displayName={displayName}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
onChange={handleApiConfigChange}
triggerClassName="w-full text-ellipsis overflow-hidden"
listApiConfigMeta={listApiConfigMeta || []}
pinnedApiConfigs={pinnedApiConfigs}
togglePinnedApiConfig={togglePinnedApiConfig}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
{isTtsPlaying && (
<StandardTooltip content={t("chat:stopTts")}>
<button
aria-label={t("chat:stopTts")}
onClick={() => vscode.postMessage({ type: "stopTts" })}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<VolumeX className="w-4 h-4" />
</button>
</StandardTooltip>
)}
<SlashCommandsPopover />
<IndexingStatusBadge />
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"mr-1",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
)
// Helper function to render the text area section
const renderTextAreaSection = () => (
<div
className={cn(
"relative",
"flex-1",
"flex",
"flex-col-reverse",
"min-h-0",
"overflow-hidden",
"rounded",
)}>
<div
ref={highlightLayerRef}
data-testid="highlight-layer"
className={cn(
"absolute",
"inset-0",
"pointer-events-none",
"whitespace-pre-wrap",
"break-words",
"text-transparent",
"overflow-hidden",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isEditMode ? "pt-1.5 pb-10 px-2" : "py-1.5 px-2",
"px-[8px]",
"pr-9",
"z-10",
"forced-color-adjust-none",
)}
style={{
color: "transparent",
}}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={3}
maxRows={15}
autoFocus={true}
className={cn(
"w-full",
"text-vscode-input-foreground",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"cursor-text",
isEditMode ? "pt-1.5 pb-10 px-2" : "py-1.5 px-2",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isDraggingOver
? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
: "bg-vscode-input-background",
"transition-background-color duration-150 ease-in-out",
"will-change-background-color",
"min-h-[90px]",
"box-border",
"rounded",
"resize-none",
"overflow-x-hidden",
"overflow-y-auto",
"pr-9",
"flex-none flex-grow",
"z-[2]",
"scrollbar-none",
"scrollbar-hide",
)}
onScroll={() => updateHighlights()}
/>
<div className="absolute top-1 right-1 z-30">
<StandardTooltip content={t("chat:enhancePrompt")}>
<button
aria-label={t("chat:enhancePrompt")}
disabled={false}
onClick={handleEnhancePrompt}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
</button>
</StandardTooltip>
</div>
{!isEditMode && (
<div className="absolute bottom-1 right-1 z-30">
<StandardTooltip content={t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
disabled={false}
onClick={onSend}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
)}
{!inputValue && !isEditMode && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height"
style={{
bottom: "0.25rem",
color: "color-mix(in oklab, var(--vscode-input-foreground) 50%, transparent)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
)
return (
<div
className={cn(
@ -1160,12 +901,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"flex-col",
"gap-1",
"bg-editor-background",
isEditMode ? "px-0" : "px-1.5",
"px-1.5",
"pb-1",
"outline-none",
"border",
"border-none",
isEditMode ? "w-full" : "w-[calc(100%-16px)]",
"w-[calc(100%-16px)]",
"ml-auto",
"mr-auto",
"box-border",
@ -1228,23 +969,168 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
)}
{renderTextAreaSection()}
</div>
<div
className={cn(
"relative",
"flex-1",
"flex",
"flex-col-reverse",
"min-h-0",
"overflow-hidden",
"rounded",
)}>
<div
ref={highlightLayerRef}
data-testid="highlight-layer"
className={cn(
"absolute",
"inset-0",
"pointer-events-none",
"whitespace-pre-wrap",
"break-words",
"text-transparent",
"overflow-hidden",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
"px-[8px]",
"py-1.5",
"pr-9",
"z-10",
"forced-color-adjust-none",
)}
style={{
color: "transparent",
}}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
{isEditMode && (
<EditModeControls
mode={mode}
onModeChange={handleModeChange}
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
onCancel={onCancel}
onSend={onSend}
onSelectImages={onSelectImages}
sendingDisabled={sendingDisabled}
shouldDisableImages={shouldDisableImages}
/>
)}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={3}
maxRows={15}
autoFocus={true}
className={cn(
"w-full",
"text-vscode-input-foreground",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"cursor-text",
"py-1.5 px-2",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isDraggingOver
? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
: "bg-vscode-input-background",
"transition-background-color duration-150 ease-in-out",
"will-change-background-color",
"min-h-[90px]",
"box-border",
"rounded",
"resize-none",
"overflow-x-hidden",
"overflow-y-auto",
"pr-9",
"flex-none flex-grow",
"z-[2]",
"scrollbar-none",
"scrollbar-hide",
)}
onScroll={() => updateHighlights()}
/>
<div className="absolute top-1 right-1 z-30">
<StandardTooltip content={t("chat:enhancePrompt")}>
<button
aria-label={t("chat:enhancePrompt")}
disabled={false}
onClick={handleEnhancePrompt}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
</button>
</StandardTooltip>
</div>
<div className="absolute bottom-1 right-1 z-30">
<StandardTooltip content={t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
disabled={false}
onClick={onSend}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
{!inputValue && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height"
style={{
bottom: "0.25rem",
color: "color-mix(in oklab, var(--vscode-input-foreground) 50%, transparent)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
</div>
</div>
{selectedImages.length > 0 && (
@ -1259,10 +1145,81 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
)}
{!isEditMode && renderNonEditModeControls()}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<div className="max-w-32">
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={handleModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
</div>
<div className="max-w-32">
<ApiConfigSelector
value={currentConfigId}
displayName={displayName}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
onChange={handleApiConfigChange}
triggerClassName="w-full text-ellipsis overflow-hidden"
listApiConfigMeta={listApiConfigMeta || []}
pinnedApiConfigs={pinnedApiConfigs}
togglePinnedApiConfig={togglePinnedApiConfig}
/>
</div>
</div>
<div className="flex items-center gap-0.5">
{isTtsPlaying && (
<StandardTooltip content={t("chat:stopTts")}>
<button
aria-label={t("chat:stopTts")}
onClick={() => vscode.postMessage({ type: "stopTts" })}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<VolumeX className="w-4 h-4" />
</button>
</StandardTooltip>
)}
<SlashCommandsPopover />
<IndexingStatusBadge />
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"mr-1",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
</div>
)
},
)
export default ChatTextArea

View file

@ -6,6 +6,7 @@ import removeMd from "remove-markdown"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import useSound from "use-sound"
import { LRUCache } from "lru-cache"
import { useTranslation } from "react-i18next"
import { useDebounceEffect } from "@src/utils/useDebounceEffect"
import { appendImages } from "@src/utils/imageUtils"
@ -30,7 +31,6 @@ import {
findLongestPrefixMatch,
parseCommand,
} from "@src/utils/command-validation"
import { useTranslation } from "react-i18next"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
@ -48,7 +48,7 @@ import HistoryPreview from "../history/HistoryPreview"
import Announcement from "./Announcement"
import BrowserSessionRow from "./BrowserSessionRow"
import ChatRow from "./ChatRow"
import ChatTextArea from "./ChatTextArea"
import { ChatTextArea } from "./ChatTextArea"
import TaskHeader from "./TaskHeader"
import AutoApproveMenu from "./AutoApproveMenu"
import SystemPromptWarning from "./SystemPromptWarning"
@ -68,7 +68,7 @@ export interface ChatViewRef {
acceptInput: () => void
}
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
@ -77,13 +77,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
ref,
) => {
const isMountedRef = useRef(true)
const [audioBaseUri] = useState(() => {
const w = window as any
return w.AUDIO_BASE_URI || ""
})
const { t } = useAppTranslation()
const { t: tSettings } = useTranslation("settings")
const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}, ${isMac ? "⌘" : "Ctrl"} + Shift + . ${t("chat:forPreviousMode")}`
const {
clineMessages: messages,
currentTaskItem,
@ -121,6 +124,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
} = useExtensionState()
const messagesRef = useRef(messages)
useEffect(() => {
messagesRef.current = messages
}, [messages])
@ -238,9 +242,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
soundEnabled,
}
const getAudioUrl = (path: string) => {
return `${audioBaseUri}/${path}`
}
const getAudioUrl = (path: string) => `${audioBaseUri}/${path}`
// Use the getAudioUrl helper function
const [playNotification] = useSound(getAudioUrl("notification.wav"), soundConfig)
@ -1456,17 +1458,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask")
// Function to switch to a specific mode
const switchToMode = useCallback(
(modeSlug: string): void => {
// Update local state and notify extension to sync mode change
// Update local state and notify extension to sync mode change.
setMode(modeSlug)
// Send the mode switch message
vscode.postMessage({
type: "mode",
text: modeSlug,
})
// Send the mode switch message.
vscode.postMessage({ type: "mode", text: modeSlug })
},
[setMode],
)
@ -1760,9 +1758,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[switchToNextMode, switchToPreviousMode],
)
// Add event listener
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
@ -1789,6 +1787,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText || isStreaming
console.log("ChatView - render()")
return (
<div
data-testid="chat-view"

View file

@ -10,6 +10,13 @@ import {
VSCodeCheckbox,
} from "@vscode/webview-ui-toolkit/react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { AlertTriangle } from "lucide-react"
import { CODEBASE_INDEX_DEFAULTS } from "@roo-code/types"
import type { EmbedderProvider } from "@roo/embeddingModels"
import type { IndexingStatus } from "@roo/ExtensionMessage"
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useAppTranslation } from "@src/i18n/TranslationContext"
@ -32,16 +39,11 @@ import {
AlertDialogTrigger,
Popover,
PopoverContent,
PopoverTrigger,
Slider,
StandardTooltip,
} from "@src/components/ui"
import { AlertTriangle } from "lucide-react"
import { useRooPortal } from "@src/components/ui/hooks/useRooPortal"
import { useEscapeKey } from "@src/hooks/useEscapeKey"
import type { EmbedderProvider } from "@roo/embeddingModels"
import type { IndexingStatus } from "@roo/ExtensionMessage"
import { CODEBASE_INDEX_DEFAULTS } from "@roo-code/types"
// Default URLs for providers
const DEFAULT_QDRANT_URL = "http://localhost:6333"
@ -525,7 +527,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
setOpen(newOpen)
}
}}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
{children}
<PopoverContent
className="w-[calc(100vw-32px)] max-w-[450px] max-h-[80vh] overflow-y-auto p-0"
align="end"

View file

@ -1,115 +0,0 @@
import React from "react"
import { Mode } from "@roo/modes"
import { Button, StandardTooltip } from "@/components/ui"
import { Image, SendHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import ModeSelector from "./ModeSelector"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface EditModeControlsProps {
mode: Mode
onModeChange: (value: Mode) => void
modeShortcutText: string
customModes: any
customModePrompts: any
onCancel?: () => void
onSend: () => void
onSelectImages: () => void
sendingDisabled: boolean
shouldDisableImages: boolean
}
export const EditModeControls: React.FC<EditModeControlsProps> = ({
mode,
onModeChange,
modeShortcutText,
customModes,
customModePrompts,
onCancel,
onSend,
onSelectImages,
sendingDisabled,
shouldDisableImages,
}) => {
const { t } = useAppTranslation()
return (
<div
className={cn(
"flex",
"items-center",
"justify-between",
"absolute",
"bottom-2",
"left-2",
"right-2",
"z-30",
)}>
<div className={cn("flex", "items-center", "gap-1", "flex-1", "min-w-0")}>
<div className="shrink-0">
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={onModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0", "ml-2")}>
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={sendingDisabled}
className="text-xs bg-vscode-toolbar-hoverBackground hover:bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground">
Cancel
</Button>
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
<StandardTooltip content={t("chat:save.tooltip")}>
<button
aria-label={t("chat:save.tooltip")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? onSend : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
)
}

View file

@ -1,10 +1,11 @@
import { cn } from "@/lib/utils"
import { StandardTooltip } from "@/components/ui"
import { cn } from "@src/lib/utils"
import { Button, StandardTooltip } from "@src/components/ui"
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
iconClass: string
title: string
disabled?: boolean
tooltip?: boolean
isLoading?: boolean
style?: React.CSSProperties
}
@ -14,39 +15,34 @@ export const IconButton: React.FC<IconButtonProps> = ({
title,
className,
disabled,
tooltip = true,
isLoading,
onClick,
style,
...props
}) => {
const buttonClasses = cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!disabled && "cursor-pointer",
disabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
className,
)
const iconClasses = cn("codicon", iconClass, isLoading && "codicon-modifier-spin")
const button = (
<button
}) => (
<StandardTooltip content={tooltip ? title : undefined}>
<Button
aria-label={title}
className={buttonClasses}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!disabled && "cursor-pointer",
disabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
className,
)}
disabled={disabled}
onClick={!disabled ? onClick : undefined}
style={{ fontSize: 16.5, ...style }}
{...props}>
<span className={iconClasses} />
</button>
)
return <StandardTooltip content={title}>{button}</StandardTooltip>
}
<span className={cn("codicon", iconClass, isLoading && "codicon-modifier-spin")} />
</Button>
</StandardTooltip>
)

View file

@ -1,13 +1,17 @@
import React, { useState, useEffect, useMemo } from "react"
import { Database } from "lucide-react"
import { cn } from "@src/lib/utils"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useTooltip } from "@/hooks/useTooltip"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { CodeIndexPopover } from "./CodeIndexPopover"
import type { IndexingStatus, IndexingStatusUpdateMessage } from "@roo/ExtensionMessage"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { PopoverTrigger, StandardTooltip, Button } from "@src/components/ui"
import { CodeIndexPopover } from "./CodeIndexPopover"
interface IndexingStatusBadgeProps {
className?: string
}
@ -15,8 +19,6 @@ interface IndexingStatusBadgeProps {
export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ className }) => {
const { t } = useAppTranslation()
const { cwd } = useExtensionState()
const { showTooltip, handleMouseEnter, handleMouseLeave, cleanup } = useTooltip({ delay: 300 })
const [isHovered, setIsHovered] = useState(false)
const [indexingStatus, setIndexingStatus] = useState<IndexingStatus>({
systemStatus: "Standby",
@ -26,10 +28,10 @@ export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ classN
})
useEffect(() => {
// Request initial indexing status
// Request initial indexing status.
vscode.postMessage({ type: "requestIndexingStatus" })
// Set up message listener for status updates
// Set up message listener for status updates.
const handleMessage = (event: MessageEvent<IndexingStatusUpdateMessage>) => {
if (event.data.type === "indexingStatusUpdate") {
const status = event.data.values
@ -43,11 +45,9 @@ export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ classN
return () => {
window.removeEventListener("message", handleMessage)
cleanup()
}
}, [cleanup, cwd])
}, [cwd])
// Calculate progress percentage with memoization
const progressPercentage = useMemo(
() =>
indexingStatus.totalItems > 0
@ -56,8 +56,7 @@ export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ classN
[indexingStatus.processedItems, indexingStatus.totalItems],
)
// Get tooltip text with internationalization
const getTooltipText = () => {
const tooltipText = useMemo(() => {
switch (indexingStatus.systemStatus) {
case "Standby":
return t("chat:indexingStatus.ready")
@ -70,93 +69,44 @@ export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ classN
default:
return t("chat:indexingStatus.status")
}
}
}, [indexingStatus.systemStatus, progressPercentage, t])
const handleMouseEnterButton = () => {
setIsHovered(true)
handleMouseEnter()
}
const handleMouseLeaveButton = () => {
setIsHovered(false)
handleMouseLeave()
}
// Get status color classes for the badge dot
const getStatusColorClass = () => {
const statusColorClass = useMemo(() => {
const statusColors = {
Standby: {
default: "bg-vscode-descriptionForeground/60",
hover: "bg-vscode-descriptionForeground/80",
},
Indexing: {
default: "bg-yellow-500 animate-pulse",
hover: "bg-yellow-500 animate-pulse",
},
Indexed: {
default: "bg-green-500",
hover: "bg-green-500",
},
Error: {
default: "bg-red-500",
hover: "bg-red-500",
},
Standby: "bg-vscode-descriptionForeground/60",
Indexing: "bg-yellow-500 animate-pulse",
Indexed: "bg-green-500",
Error: "bg-red-500",
}
const colors = statusColors[indexingStatus.systemStatus as keyof typeof statusColors] || statusColors.Standby
return isHovered ? colors.hover : colors.default
}
return statusColors[indexingStatus.systemStatus as keyof typeof statusColors] || statusColors.Standby
}, [indexingStatus.systemStatus])
return (
<div className={cn("relative inline-block", className)}>
<CodeIndexPopover indexingStatus={indexingStatus}>
<button
onMouseEnter={handleMouseEnterButton}
onMouseLeave={handleMouseLeaveButton}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-85 text-vscode-foreground",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
className,
)}
aria-label={getTooltipText()}>
{/* File search icon */}
<Database className="w-4 h-4 text-vscode-foreground" />
{/* Status dot badge */}
<span
<CodeIndexPopover indexingStatus={indexingStatus}>
<StandardTooltip content={tooltipText}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label={tooltipText}
className={cn(
"absolute top-1 right-1 w-1.5 h-1.5 rounded-full transition-colors duration-200",
getStatusColorClass(),
)}
/>
</button>
</CodeIndexPopover>
{showTooltip && (
<div
className={cn(
"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2",
"px-2 py-1 text-xs font-medium text-vscode-foreground",
"bg-vscode-editor-background border border-vscode-panel-border",
"rounded shadow-lg whitespace-nowrap z-50",
)}
role="tooltip">
{getTooltipText()}
<div
className={cn(
"absolute top-full left-1/2 transform -translate-x-1/2",
"w-0 h-0 border-l-4 border-r-4 border-t-4",
"border-l-transparent border-r-transparent border-t-vscode-panel-border",
)}
/>
</div>
)}
</div>
"relative h-7 w-7 p-0",
"text-vscode-foreground opacity-85",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
className,
)}>
<Database className="w-4 h-4" />
<span
className={cn(
"absolute top-1 right-1 w-1.5 h-1.5 rounded-full transition-colors duration-200",
statusColorClass,
)}
/>
</Button>
</PopoverTrigger>
</StandardTooltip>
</CodeIndexPopover>
)
}

View file

@ -1,26 +1,28 @@
import React from "react"
import { Fzf } from "fzf"
import { ChevronUp, Check, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { IconButton } from "./IconButton"
import { type ModeConfig, type CustomModePrompts, TelemetryEventName } from "@roo-code/types"
import { type Mode, getAllModes } from "@roo/modes"
import { vscode } from "@/utils/vscode"
import { telemetryClient } from "@/utils/TelemetryClient"
import { cn } from "@/lib/utils"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Mode, getAllModes } from "@roo/modes"
import { ModeConfig, CustomModePrompts } from "@roo-code/types"
import { telemetryClient } from "@/utils/TelemetryClient"
import { TelemetryEventName } from "@roo-code/types"
import { Fzf } from "fzf"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { IconButton } from "./IconButton"
// Minimum number of modes required to show search functionality
const SEARCH_THRESHOLD = 6
interface ModeSelectorProps {
value: Mode
onChange: (value: Mode) => void
disabled?: boolean
title?: string
title: string
triggerClassName?: string
modeShortcutText: string
customModes?: ModeConfig[]
@ -32,7 +34,7 @@ export const ModeSelector = ({
value,
onChange,
disabled = false,
title = "",
title,
triggerClassName = "",
modeShortcutText,
customModes,
@ -47,29 +49,31 @@ export const ModeSelector = ({
const { t } = useAppTranslation()
const trackModeSelectorOpened = React.useCallback(() => {
// Track telemetry every time the mode selector is opened
// Track telemetry every time the mode selector is opened.
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
// Track first-time usage for UI purposes
// Track first-time usage for UI purposes.
if (!hasOpenedModeSelector) {
setHasOpenedModeSelector(true)
vscode.postMessage({ type: "hasOpenedModeSelector", bool: true })
}
}, [hasOpenedModeSelector, setHasOpenedModeSelector])
// Get all modes including custom modes and merge custom prompt descriptions
// Get all modes including custom modes and merge custom prompt descriptions.
const modes = React.useMemo(() => {
const allModes = getAllModes(customModes)
return allModes.map((mode) => ({
...mode,
description: customModePrompts?.[mode.slug]?.description ?? mode.description,
}))
}, [customModes, customModePrompts])
// Find the selected mode
// Find the selected mode.
const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value])
// Memoize searchable items for fuzzy search with separate name and description search
// Memoize searchable items for fuzzy search with separate name and
// description search.
const nameSearchItems = React.useMemo(() => {
return modes.map((mode) => ({
original: mode,
@ -84,31 +88,29 @@ export const ModeSelector = ({
}))
}, [modes])
// Create memoized Fzf instances for name and description searches
const nameFzfInstance = React.useMemo(() => {
return new Fzf(nameSearchItems, {
selector: (item) => item.searchStr,
})
}, [nameSearchItems])
// Create memoized Fzf instances for name and description searches.
const nameFzfInstance = React.useMemo(
() => new Fzf(nameSearchItems, { selector: (item) => item.searchStr }),
[nameSearchItems],
)
const descriptionFzfInstance = React.useMemo(() => {
return new Fzf(descriptionSearchItems, {
selector: (item) => item.searchStr,
})
}, [descriptionSearchItems])
const descriptionFzfInstance = React.useMemo(
() => new Fzf(descriptionSearchItems, { selector: (item) => item.searchStr }),
[descriptionSearchItems],
)
// Filter modes based on search value using fuzzy search with priority
// Filter modes based on search value using fuzzy search with priority.
const filteredModes = React.useMemo(() => {
if (!searchValue) return modes
// First search in names/slugs
// First search in names/slugs.
const nameMatches = nameFzfInstance.find(searchValue)
const nameMatchedModes = new Set(nameMatches.map((result) => result.item.original.slug))
// Then search in descriptions
// Then search in descriptions.
const descriptionMatches = descriptionFzfInstance.find(searchValue)
// Combine results: name matches first, then description matches
// Combine results: name matches first, then description matches.
const combinedResults = [
...nameMatches.map((result) => result.item.original),
...descriptionMatches
@ -128,7 +130,7 @@ export const ModeSelector = ({
(modeSlug: string) => {
onChange(modeSlug as Mode)
setOpen(false)
// Clear search after selection
// Clear search after selection.
setSearchValue("")
},
[onChange],
@ -138,7 +140,8 @@ export const ModeSelector = ({
(isOpen: boolean) => {
if (isOpen) trackModeSelectorOpened()
setOpen(isOpen)
// Clear search when closing
// Clear search when closing.
if (!isOpen) {
setSearchValue("")
}
@ -146,44 +149,46 @@ export const ModeSelector = ({
[trackModeSelectorOpened],
)
// Auto-focus search input when popover opens
// Auto-focus search input when popover opens.
React.useEffect(() => {
if (open && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [open])
// Determine if search should be shown
// Determine if search should be shown.
const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD
// Combine instruction text for tooltip
// Combine instruction text for tooltip.
const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`
const trigger = (
<PopoverTrigger
disabled={disabled}
data-testid="mode-selector-trigger"
className={cn(
"inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
!disabled && !hasOpenedModeSelector
? "bg-primary opacity-90 hover:bg-primary-hover text-vscode-button-foreground"
: null,
)}>
<ChevronUp className="pointer-events-none opacity-80 flex-shrink-0 size-3" />
<span className="truncate">{selectedMode?.name || ""}</span>
</PopoverTrigger>
)
return (
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}
<StandardTooltip content={title}>
<PopoverTrigger
disabled={disabled}
data-testid="mode-selector-trigger"
className={cn(
"inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
!disabled && !hasOpenedModeSelector
? "bg-primary opacity-90 hover:bg-primary-hover text-vscode-button-foreground"
: null,
)}>
<ChevronUp
className={cn(
"pointer-events-none opacity-80 flex-shrink-0 size-3 transition-transform duration-200",
open && "rotate-180",
)}
/>
<span className="truncate">{selectedMode?.name || ""}</span>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
align="start"
sideOffset={4}
@ -274,10 +279,7 @@ export const ModeSelector = ({
iconClass="codicon-settings-gear"
title={t("chat:modeSelector.settings")}
onClick={() => {
vscode.postMessage({
type: "switchTab",
tab: "modes",
})
vscode.postMessage({ type: "switchTab", tab: "modes" })
setOpen(false)
}}
/>
@ -300,5 +302,3 @@ export const ModeSelector = ({
</Popover>
)
}
export default ModeSelector

View file

@ -41,26 +41,24 @@ export const SlashCommandsPopover: React.FC<SlashCommandsPopoverProps> = ({ clas
}
}
const trigger = (
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 w-7 p-0",
"text-vscode-foreground opacity-85",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
className,
)}>
<Zap className="w-4 h-4" />
</Button>
</PopoverTrigger>
)
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<StandardTooltip content={t("chat:slashCommands.tooltip")}>{trigger}</StandardTooltip>
<StandardTooltip content={t("chat:slashCommands.tooltip")}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 w-7 p-0",
"text-vscode-foreground opacity-85",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
className,
)}>
<Zap className="w-4 h-4" />
</Button>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
align="start"

View file

@ -49,6 +49,7 @@ describe("ApiConfigSelector", () => {
const defaultProps = {
value: "config1",
displayName: "Config 1",
title: "API Config",
onChange: mockOnChange,
listApiConfigMeta: [
{ id: "config1", name: "Config 1" },
@ -75,8 +76,8 @@ describe("ApiConfigSelector", () => {
render(<ApiConfigSelector {...defaultProps} />)
const trigger = screen.getByTestId("dropdown-trigger")
// Check for the icon by looking for the codicon span element
const icon = trigger.querySelector(".codicon-chevron-up")
// Check for the icon by looking for the svg element (ChevronUp from lucide-react renders as svg)
const icon = trigger.querySelector("svg")
expect(icon).toBeInTheDocument()
})

View file

@ -1,12 +1,11 @@
import { render, fireEvent, screen } from "@/utils/test-utils"
import { defaultModeSlug } from "@roo/modes"
import { render, fireEvent, screen } from "@src/utils/test-utils"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import * as pathMentions from "@src/utils/path-mentions"
import ChatTextArea from "../ChatTextArea"
import { ChatTextArea } from "../ChatTextArea"
vi.mock("@src/utils/vscode", () => ({
vscode: {
@ -1058,54 +1057,4 @@ describe("ChatTextArea", () => {
expect(apiConfigDropdown).toHaveAttribute("disabled")
})
})
describe("edit mode integration", () => {
it("should render edit mode UI when isEditMode is true", () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
taskHistory: [],
cwd: "/test/workspace",
customModes: [],
customModePrompts: {},
})
render(<ChatTextArea {...defaultProps} isEditMode={true} />)
// The edit mode UI should be rendered
// We can verify this by checking for the presence of elements that are unique to edit mode
const cancelButton = screen.getByRole("button", { name: /cancel/i })
expect(cancelButton).toBeInTheDocument()
// Should show save button instead of send button
const saveButton = screen.getByRole("button", { name: /save/i })
expect(saveButton).toBeInTheDocument()
// Should not show send button in edit mode
const sendButton = screen.queryByRole("button", { name: /send.*message/i })
expect(sendButton).not.toBeInTheDocument()
})
it("should not render edit mode UI when isEditMode is false", () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
taskHistory: [],
cwd: "/test/workspace",
})
render(<ChatTextArea {...defaultProps} isEditMode={false} />)
// The edit mode UI should not be rendered
const cancelButton = screen.queryByRole("button", { name: /cancel/i })
expect(cancelButton).not.toBeInTheDocument()
// Should show send button when not in edit mode
const sendButton = screen.getByRole("button", { name: /send.*message/i })
expect(sendButton).toBeInTheDocument()
// Should not show save button when not in edit mode
const saveButton = screen.queryByRole("button", { name: /save/i })
expect(saveButton).not.toBeInTheDocument()
})
})
})

View file

@ -73,16 +73,19 @@ vi.mock("react-i18next", () => ({
}))
vi.mock("../ChatTextArea", () => {
const ChatTextAreaComponent = React.forwardRef(function MockChatTextArea(
_props: any,
ref: React.ForwardedRef<{ focus: () => void }>,
) {
React.useImperativeHandle(ref, () => ({
focus: vi.fn(),
}))
return <div data-testid="chat-textarea" />
})
return {
default: React.forwardRef(function MockChatTextArea(
_props: any,
ref: React.ForwardedRef<{ focus: () => void }>,
) {
React.useImperativeHandle(ref, () => ({
focus: vi.fn(),
}))
return <div data-testid="chat-textarea" />
}),
default: ChatTextAreaComponent,
ChatTextArea: ChatTextAreaComponent, // Export as named export too
}
})

View file

@ -181,30 +181,33 @@ vi.mock("../ChatTextArea", () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mockReact = require("react")
return {
default: mockReact.forwardRef(function MockChatTextArea(
props: ChatTextAreaProps,
ref: React.ForwardedRef<{ focus: () => void }>,
) {
// Use useImperativeHandle to expose the mock focus method
React.useImperativeHandle(ref, () => ({
focus: mockFocus,
}))
const ChatTextAreaComponent = mockReact.forwardRef(function MockChatTextArea(
props: ChatTextAreaProps,
ref: React.ForwardedRef<{ focus: () => void }>,
) {
// Use useImperativeHandle to expose the mock focus method
mockReact.useImperativeHandle(ref, () => ({
focus: mockFocus,
}))
return (
<div data-testid="chat-textarea">
<input
ref={mockInputRef}
type="text"
onChange={(e) => {
// With message queueing, onSend is always called (it handles queueing internally)
props.onSend(e.target.value)
}}
data-sending-disabled={props.sendingDisabled}
/>
</div>
)
}),
return (
<div data-testid="chat-textarea">
<input
ref={mockInputRef}
type="text"
onChange={(e) => {
// With message queueing, onSend is always called (it handles queueing internally)
props.onSend(e.target.value)
}}
data-sending-disabled={props.sendingDisabled}
/>
</div>
)
})
return {
default: ChatTextAreaComponent,
ChatTextArea: ChatTextAreaComponent, // Export as named export too
}
})

View file

@ -1,138 +0,0 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { EditModeControls } from "../EditModeControls"
import { Mode } from "@roo/modes"
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock the UI components
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
),
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
}))
// Mock ModeSelector
vi.mock("../ModeSelector", () => ({
default: ({ value, onChange, title }: any) => (
<select value={value} onChange={(e) => onChange(e.target.value)} title={title}>
<option value="code">Code</option>
<option value="architect">Architect</option>
</select>
),
}))
describe("EditModeControls", () => {
const defaultProps = {
mode: "code" as Mode,
onModeChange: vi.fn(),
modeShortcutText: "Ctrl+M",
customModes: [],
customModePrompts: {},
onCancel: vi.fn(),
onSend: vi.fn(),
onSelectImages: vi.fn(),
sendingDisabled: false,
shouldDisableImages: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
it("renders all controls correctly", () => {
render(<EditModeControls {...defaultProps} />)
// Check for mode selector
expect(screen.getByTitle("chat:selectMode")).toBeInTheDocument()
// Check for Cancel button
expect(screen.getByText("Cancel")).toBeInTheDocument()
// Check for image button
expect(screen.getByTitle("chat:addImages")).toBeInTheDocument()
// Check for send button
expect(screen.getByTitle("chat:save.tooltip")).toBeInTheDocument()
})
it("calls onCancel when Cancel button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const cancelButton = screen.getByText("Cancel")
fireEvent.click(cancelButton)
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it("calls onSend when send button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const sendButton = screen.getByLabelText("chat:save.tooltip")
fireEvent.click(sendButton)
expect(defaultProps.onSend).toHaveBeenCalledTimes(1)
})
it("calls onSelectImages when image button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const imageButton = screen.getByLabelText("chat:addImages")
fireEvent.click(imageButton)
expect(defaultProps.onSelectImages).toHaveBeenCalledTimes(1)
})
it("disables buttons when sendingDisabled is true", () => {
render(<EditModeControls {...defaultProps} sendingDisabled={true} />)
const cancelButton = screen.getByText("Cancel")
const sendButton = screen.getByLabelText("chat:save.tooltip")
expect(cancelButton).toBeDisabled()
expect(sendButton).toBeDisabled()
})
it("disables image button when shouldDisableImages is true", () => {
render(<EditModeControls {...defaultProps} shouldDisableImages={true} />)
const imageButton = screen.getByLabelText("chat:addImages")
expect(imageButton).toBeDisabled()
})
it("does not call onSelectImages when image button is disabled", () => {
render(<EditModeControls {...defaultProps} shouldDisableImages={true} />)
const imageButton = screen.getByLabelText("chat:addImages")
fireEvent.click(imageButton)
expect(defaultProps.onSelectImages).not.toHaveBeenCalled()
})
it("does not call onSend when send button is disabled", () => {
render(<EditModeControls {...defaultProps} sendingDisabled={true} />)
const sendButton = screen.getByLabelText("chat:save.tooltip")
fireEvent.click(sendButton)
expect(defaultProps.onSend).not.toHaveBeenCalled()
})
it("calls onModeChange when mode is changed", () => {
render(<EditModeControls {...defaultProps} />)
const modeSelector = screen.getByTitle("chat:selectMode")
fireEvent.change(modeSelector, { target: { value: "architect" } })
expect(defaultProps.onModeChange).toHaveBeenCalledWith("architect")
})
})

View file

@ -55,16 +55,6 @@ vi.mock("@src/utils/vscode", () => ({
},
}))
// Mock the useTooltip hook
vi.mock("@/hooks/useTooltip", () => ({
useTooltip: vi.fn(() => ({
showTooltip: false,
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
cleanup: vi.fn(),
})),
}))
// Mock the ExtensionStateContext
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({

View file

@ -1,11 +1,11 @@
import React from "react"
import { render, screen, fireEvent } from "@/utils/test-utils"
import { describe, test, expect, vi } from "vitest"
import ModeSelector from "../ModeSelector"
import { Mode } from "@roo/modes"
import { ModeConfig } from "@roo-code/types"
// Mock the dependencies
import type { ModeConfig } from "@roo-code/types"
import type { Mode } from "@roo/modes"
import { ModeSelector } from "../ModeSelector"
vi.mock("@/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
@ -35,7 +35,7 @@ vi.mock("@/utils/TelemetryClient", () => ({
},
}))
// Create a variable to control what getAllModes returns
// Create a variable to control what getAllModes returns.
let mockModes: ModeConfig[] = []
vi.mock("@roo/modes", async () => {
@ -56,6 +56,7 @@ describe("ModeSelector", () => {
render(
<ModeSelector
title="Mode Selector"
value={"code" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
@ -63,19 +64,18 @@ describe("ModeSelector", () => {
/>,
)
// The component should be rendered
expect(screen.getByTestId("mode-selector-trigger")).toBeInTheDocument()
})
test("falls back to default description when no custom prompt", () => {
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
render(
<ModeSelector title="Mode Selector" value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />,
)
// The component should be rendered
expect(screen.getByTestId("mode-selector-trigger")).toBeInTheDocument()
})
test("shows search bar when there are more than 6 modes", () => {
// Set up mock to return 7 modes
mockModes = Array.from({ length: 7 }, (_, i) => ({
slug: `mode-${i}`,
name: `Mode ${i}`,
@ -84,22 +84,28 @@ describe("ModeSelector", () => {
groups: ["read", "edit"],
}))
render(<ModeSelector value={"mode-0" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
render(
<ModeSelector
title="Mode Selector"
value={"mode-0" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
/>,
)
// Click to open the popover
// Click to open the popover.
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Search input should be visible
// Search input should be visible.
expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
// Info icon should be visible
// Info icon should be visible.
expect(screen.getByText("chat:modeSelector.title")).toBeInTheDocument()
const infoIcon = document.querySelector(".codicon-info")
expect(infoIcon).toBeInTheDocument()
})
test("shows info blurb instead of search bar when there are 6 or fewer modes", () => {
// Set up mock to return 5 modes
mockModes = Array.from({ length: 5 }, (_, i) => ({
slug: `mode-${i}`,
name: `Mode ${i}`,
@ -108,24 +114,30 @@ describe("ModeSelector", () => {
groups: ["read", "edit"],
}))
render(<ModeSelector value={"mode-0" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
render(
<ModeSelector
title="Mode Selector"
value={"mode-0" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
/>,
)
// Click to open the popover
// Click to open the popover.
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Search input should NOT be visible
// Search input should NOT be visible.
expect(screen.queryByTestId("mode-search-input")).not.toBeInTheDocument()
// Info blurb should be visible
// Info blurb should be visible.
expect(screen.getByText(/chat:modeSelector.description/)).toBeInTheDocument()
// Info icon should NOT be visible
// Info icon should NOT be visible.
const infoIcon = document.querySelector(".codicon-info")
expect(infoIcon).not.toBeInTheDocument()
})
test("filters modes correctly when searching", () => {
// Set up mock to return 7 modes to enable search
mockModes = Array.from({ length: 7 }, (_, i) => ({
slug: `mode-${i}`,
name: `Mode ${i}`,
@ -134,22 +146,28 @@ describe("ModeSelector", () => {
groups: ["read", "edit"],
}))
render(<ModeSelector value={"mode-0" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
render(
<ModeSelector
title="Mode Selector"
value={"mode-0" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
/>,
)
// Click to open the popover
// Click to open the popover.
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Type in search
// Type in search.
const searchInput = screen.getByTestId("mode-search-input")
fireEvent.change(searchInput, { target: { value: "Mode 3" } })
// Should show filtered results
// Should show filtered results.
const modeItems = screen.getAllByTestId("mode-selector-item")
expect(modeItems.length).toBeLessThan(7) // Should have filtered some out
expect(modeItems.length).toBeLessThan(7) // Should have filtered some out.
})
test("respects disableSearch prop even when there are more than 6 modes", () => {
// Set up mock to return 10 modes
mockModes = Array.from({ length: 10 }, (_, i) => ({
slug: `mode-${i}`,
name: `Mode ${i}`,
@ -159,25 +177,30 @@ describe("ModeSelector", () => {
}))
render(
<ModeSelector value={"mode-0" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" disableSearch={true} />,
<ModeSelector
title="Mode Selector"
value={"mode-0" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
disableSearch={true}
/>,
)
// Click to open the popover
// Click to open the popover.
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Search input should NOT be visible even with 10 modes
// Search input should NOT be visible even with 10 modes.
expect(screen.queryByTestId("mode-search-input")).not.toBeInTheDocument()
// Info blurb should be visible instead
// Info blurb should be visible instead.
expect(screen.getByText(/chat:modeSelector.description/)).toBeInTheDocument()
// Info icon should NOT be visible
// Info icon should NOT be visible.
const infoIcon = document.querySelector(".codicon-info")
expect(infoIcon).not.toBeInTheDocument()
})
test("shows search when disableSearch is false (default) and modes > 6", () => {
// Set up mock to return 8 modes
mockModes = Array.from({ length: 8 }, (_, i) => ({
slug: `mode-${i}`,
name: `Mode ${i}`,
@ -186,16 +209,20 @@ describe("ModeSelector", () => {
groups: ["read", "edit"],
}))
// Don't pass disableSearch prop (should default to false)
render(<ModeSelector value={"mode-0" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
// Don't pass disableSearch prop (should default to false).
render(
<ModeSelector
title="Mode Selector"
value={"mode-0" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
/>,
)
// Click to open the popover
fireEvent.click(screen.getByTestId("mode-selector-trigger"))
// Search input should be visible
expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
// Info icon should be visible
const infoIcon = document.querySelector(".codicon-info")
expect(infoIcon).toBeInTheDocument()
})

View file

@ -3,7 +3,7 @@ import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import styled from "styled-components"
import { Trans } from "react-i18next"
import { TelemetrySetting } from "@roo/TelemetrySetting"
import type { TelemetrySetting } from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@src/i18n/TranslationContext"

View file

@ -2,11 +2,11 @@ import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Trans } from "react-i18next"
import { Info, Download, Upload, TriangleAlert } from "lucide-react"
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import type { TelemetrySetting } from "@roo-code/types"
import { Package } from "@roo/package"
import { TelemetrySetting } from "@roo/TelemetrySetting"
import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"

View file

@ -25,11 +25,10 @@ import {
LucideIcon,
} from "lucide-react"
import type { ProviderSettings, ExperimentId } from "@roo-code/types"
import { TelemetrySetting } from "@roo/TelemetrySetting"
import type { ProviderSettings, ExperimentId, TelemetrySetting } from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
import { cn } from "@src/lib/utils"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext"
import {
@ -65,7 +64,6 @@ import { LanguageSettings } from "./LanguageSettings"
import { About } from "./About"
import { Section } from "./Section"
import PromptsSettings from "./PromptsSettings"
import { cn } from "@/lib/utils"
export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden"
export const settingsTabList =

View file

@ -1,13 +1,14 @@
import * as React from "react"
import { ReactNode } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
export const STANDARD_TOOLTIP_DELAY = 300
interface StandardTooltipProps {
/** The element(s) that trigger the tooltip */
children: React.ReactNode
children: ReactNode
/** The content to display in the tooltip */
content: React.ReactNode
content: ReactNode
/** The preferred side of the trigger to render the tooltip */
side?: "top" | "right" | "bottom" | "left"
/** The preferred alignment against the trigger */
@ -51,7 +52,7 @@ export function StandardTooltip({
asChild = true,
maxWidth,
}: StandardTooltipProps) {
// Don't render tooltip if content is empty or only whitespace
// Don't render tooltip if content is empty or only whitespace.
if (!content || (typeof content === "string" && !content.trim())) {
return <>{children}</>
}

View file

@ -1,33 +1,44 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
}
const Tooltip = TooltipPrimitive.Root
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
const TooltipTrigger = TooltipPrimitive.Trigger
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
collisionPadding={10}
avoidCollisions={true}
className={cn(
"z-50 rounded-xs bg-vscode-notifications-background border border-vscode-notifications-border px-3 py-1.5 text-xs text-vscode-notifications-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"max-w-[300px] break-words",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-vscode-editorHoverWidget-background outline outline-vscode-editorHoverWidget-border text-vscode-editorHoverWidget-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-sm px-2 py-1 text-xs text-balance",
"max-w-[300px] break-words",
className,
)}
{...props}>
{children}
<TooltipPrimitive.Arrow className="bg-vscode-editorHoverWidget-background border-b border-r border-vscode-editorHoverWidget-border fill-vscode-editorHoverWidget-background z-50 size-1.5 translate-y-[calc(-50%_+_1px)] rotate-45" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -7,10 +7,10 @@ import {
type ModeConfig,
type ExperimentId,
type TodoItem,
type TelemetrySetting,
} from "@roo-code/types"
import { type OrganizationAllowList, ORGANIZATION_ALLOW_ALL } from "@roo/cloud"
import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata, Command } from "@roo/ExtensionMessage"
import { findLastIndex } from "@roo/array"
import { McpServer } from "@roo/mcp"
@ -18,7 +18,6 @@ import { checkExistKey } from "@roo/checkExistApiConfig"
import { Mode, defaultModeSlug, defaultPrompts } from "@roo/modes"
import { CustomSupportPrompts } from "@roo/support-prompt"
import { experimentDefault } from "@roo/experiments"
import { TelemetrySetting } from "@roo/TelemetrySetting"
import { RouterModels } from "@roo/api"
import { vscode } from "@src/utils/vscode"

View file

@ -1,39 +0,0 @@
import { useState, useCallback, useRef } from "react"
interface UseTooltipOptions {
delay?: number
}
export const useTooltip = (options: UseTooltipOptions = {}) => {
const { delay = 300 } = options
const [showTooltip, setShowTooltip] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleMouseEnter = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => setShowTooltip(true), delay)
}, [delay])
const handleMouseLeave = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setShowTooltip(false)
}, [])
// Cleanup on unmount
const cleanup = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
return {
showTooltip,
handleMouseEnter,
handleMouseLeave,
cleanup,
}
}

View file

@ -76,6 +76,7 @@
--color-vscode-button-background: var(--vscode-button-background);
--color-vscode-button-secondaryForeground: var(--vscode-button-secondaryForeground);
--color-vscode-button-secondaryBackground: var(--vscode-button-secondaryBackground);
--color-vscode-button-hoverBackground: var(--vscode-button-hoverBackground);
--color-vscode-dropdown-foreground: var(--vscode-dropdown-foreground);
--color-vscode-dropdown-background: var(--vscode-dropdown-background);
@ -107,6 +108,7 @@
--color-vscode-list-activeSelectionForeground: var(--vscode-list-activeSelectionForeground);
--color-vscode-toolbar-hoverBackground: var(--vscode-toolbar-hoverBackground);
--color-vscode-toolbar-hoverOutline: var(--vscode-toolbar-hoverOutline);
--color-vscode-panel-border: var(--vscode-panel-border);
@ -129,9 +131,14 @@
--color-vscode-inputValidation-infoBorder: var(--vscode-inputValidation-infoBorder);
--color-vscode-widget-border: var(--vscode-widget-border);
--color-vscode-textLink-foreground: var(--vscode-textLink-foreground);
--color-vscode-textCodeBlock-background: var(--vscode-textCodeBlock-background);
--color-vscode-button-hoverBackground: var(--vscode-button-hoverBackground);
--color-vscode-editorHoverWidget-foreground: var(--vscode-editorHoverWidget-foreground);
--color-vscode-editorHoverWidget-background: var(--vscode-editorHoverWidget-background);
--color-vscode-editorHoverWidget-border: var(--vscode-editorHoverWidget-border);
}
@layer base {

View file

@ -1,6 +1,6 @@
import posthog from "posthog-js"
import { TelemetrySetting } from "@roo/TelemetrySetting"
import type { TelemetrySetting } from "@roo-code/types"
class TelemetryClient {
private static instance: TelemetryClient

View file

@ -1,7 +1,8 @@
import React from "react"
import { render, RenderOptions } from "@testing-library/react"
import { TooltipProvider } from "@/components/ui/tooltip"
import { STANDARD_TOOLTIP_DELAY } from "@/components/ui/standard-tooltip"
import { TooltipProvider } from "@src/components/ui/tooltip"
import { STANDARD_TOOLTIP_DELAY } from "@src/components/ui/standard-tooltip"
interface AllTheProvidersProps {
children: React.ReactNode