Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions webview-ui/src/components/history/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const CopyButton = ({ itemTask }: CopyButtonProps) => {
size="icon"
title={t("history:copyPrompt")}
onClick={onCopy}
data-testid="copy-prompt-button"
className="opacity-50 hover:opacity-100">
<span className={cn("codicon scale-80", { "codicon-check": isCopied, "codicon-copy": !isCopied })} />
</Button>
Expand Down
19 changes: 15 additions & 4 deletions webview-ui/src/components/history/HistoryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
style={{ width: "100%" }}
placeholder={t("history:searchPlaceholder")}
value={searchQuery}
data-testid="history-search-input"
onInput={(e) => {
const newValue = (e.target as HTMLInputElement)?.value
setSearchQuery(newValue)
Expand Down Expand Up @@ -72,13 +73,22 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
value={sortOption}
role="radiogroup"
onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}>
<VSCodeRadio value="newest">{t("history:newest")}</VSCodeRadio>
<VSCodeRadio value="oldest">{t("history:oldest")}</VSCodeRadio>
<VSCodeRadio value="mostExpensive">{t("history:mostExpensive")}</VSCodeRadio>
<VSCodeRadio value="mostTokens">{t("history:mostTokens")}</VSCodeRadio>
<VSCodeRadio value="newest" data-testid="radio-newest">
{t("history:newest")}
</VSCodeRadio>
<VSCodeRadio value="oldest" data-testid="radio-oldest">
{t("history:oldest")}
</VSCodeRadio>
<VSCodeRadio value="mostExpensive" data-testid="radio-most-expensive">
{t("history:mostExpensive")}
</VSCodeRadio>
<VSCodeRadio value="mostTokens" data-testid="radio-most-tokens">
{t("history:mostTokens")}
</VSCodeRadio>
<VSCodeRadio
value="mostRelevant"
disabled={!searchQuery}
data-testid="radio-most-relevant"
style={{ opacity: searchQuery ? 1 : 0.5 }}>
{t("history:mostRelevant")}
</VSCodeRadio>
Expand Down Expand Up @@ -135,6 +145,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
variant="ghost"
size="sm"
title={t("history:deleteTaskTitle")}
data-testid="delete-task-button"
onClick={(e) => {
e.stopPropagation()

Expand Down
23 changes: 10 additions & 13 deletions webview-ui/src/components/history/__tests__/HistoryView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe("HistoryView", () => {
render(<HistoryView onDone={onDone} />)

// Get search input and radio group
const searchInput = screen.getByPlaceholderText("Fuzzy search history...")
const searchInput = screen.getByTestId("history-search-input")
const radioGroup = screen.getByRole("radiogroup")

// Type in search
Expand All @@ -85,7 +85,7 @@ describe("HistoryView", () => {
jest.advanceTimersByTime(100)

// Check if sort option automatically changes to "Most Relevant"
const mostRelevantRadio = within(radioGroup).getByLabelText("Most Relevant")
const mostRelevantRadio = within(radioGroup).getByTestId("radio-most-relevant")
expect(mostRelevantRadio).not.toBeDisabled()

// Click the radio button
Expand All @@ -95,7 +95,7 @@ describe("HistoryView", () => {
jest.advanceTimersByTime(100)

// Verify radio button is checked
const updatedRadio = within(radioGroup).getByRole("radio", { name: "Most Relevant", checked: true })
const updatedRadio = within(radioGroup).getByTestId("radio-most-relevant")
expect(updatedRadio).toBeInTheDocument()
})

Expand All @@ -106,21 +106,18 @@ describe("HistoryView", () => {
const radioGroup = screen.getByRole("radiogroup")

// Test changing sort options
const oldestRadio = within(radioGroup).getByLabelText("Oldest")
const oldestRadio = within(radioGroup).getByTestId("radio-oldest")
fireEvent.click(oldestRadio)

// Wait for oldest radio to be checked
const checkedOldestRadio = await within(radioGroup).findByRole("radio", { name: "Oldest", checked: true })
const checkedOldestRadio = within(radioGroup).getByTestId("radio-oldest")
expect(checkedOldestRadio).toBeInTheDocument()

const mostExpensiveRadio = within(radioGroup).getByLabelText("Most Expensive")
const mostExpensiveRadio = within(radioGroup).getByTestId("radio-most-expensive")
fireEvent.click(mostExpensiveRadio)

// Wait for most expensive radio to be checked
const checkedExpensiveRadio = await within(radioGroup).findByRole("radio", {
name: "Most Expensive",
checked: true,
})
const checkedExpensiveRadio = within(radioGroup).getByTestId("radio-most-expensive")
expect(checkedExpensiveRadio).toBeInTheDocument()
})

Expand Down Expand Up @@ -148,7 +145,7 @@ describe("HistoryView", () => {
fireEvent.mouseEnter(taskContainer)

// Click delete button to open confirmation dialog
const deleteButton = within(taskContainer).getByTitle("Delete Task (Shift + Click to skip confirmation)")
const deleteButton = within(taskContainer).getByTestId("delete-task-button")
fireEvent.click(deleteButton)

// Verify dialog is shown
Expand All @@ -175,7 +172,7 @@ describe("HistoryView", () => {
fireEvent.mouseEnter(taskContainer)

// Shift-click delete button
const deleteButton = within(taskContainer).getByTitle("Delete Task (Shift + Click to skip confirmation)")
const deleteButton = within(taskContainer).getByTestId("delete-task-button")
fireEvent.click(deleteButton, { shiftKey: true })

// Verify no dialog is shown
Expand Down Expand Up @@ -203,7 +200,7 @@ describe("HistoryView", () => {
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)

const copyButton = within(taskContainer).getByTitle("Copy Prompt")
const copyButton = within(taskContainer).getByTestId("copy-prompt-button")

// Click the copy button and wait for clipboard operation
await act(async () => {
Expand Down
41 changes: 24 additions & 17 deletions webview-ui/src/components/settings/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Cog } from "lucide-react"

Expand Down Expand Up @@ -29,19 +30,20 @@ export const AdvancedSettings = ({
className,
...props
}: AdvancedSettingsProps) => {
const { t } = useAppTranslation()
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<Cog className="w-4" />
<div>Advanced</div>
<div>{t("settings:sections.advanced")}</div>
</div>
</SectionHeader>

<Section>
<div>
<div className="flex flex-col gap-2">
<span className="font-medium">Rate limit</span>
<span className="font-medium">{t("settings:advanced.rateLimit.label")}</span>
<div className="flex items-center gap-2">
<input
type="range"
Expand All @@ -55,7 +57,9 @@ export const AdvancedSettings = ({
<span style={{ ...sliderLabelStyle }}>{rateLimitSeconds}s</span>
</div>
</div>
<p className="text-vscode-descriptionForeground text-sm mt-0">Minimum time between API requests.</p>
<p className="text-vscode-descriptionForeground text-sm mt-0">
{t("settings:advanced.rateLimit.description")}
</p>
</div>

<div>
Expand All @@ -69,16 +73,15 @@ export const AdvancedSettings = ({
setExperimentEnabled(EXPERIMENT_IDS.MULTI_SEARCH_AND_REPLACE, false)
}
}}>
<span className="font-medium">Enable editing through diffs</span>
<span className="font-medium">{t("settings:advanced.diff.label")}</span>
</VSCodeCheckbox>
<p className="text-vscode-descriptionForeground text-sm mt-0">
When enabled, Roo will be able to edit files more quickly and will automatically reject
truncated full-file writes. Works best with the latest Claude 3.7 Sonnet model.
{t("settings:advanced.diff.description")}
</p>
{diffEnabled && (
<div className="flex flex-col gap-2 mt-3 mb-2 pl-3 border-l-2 border-vscode-button-background">
<div className="flex flex-col gap-2">
<span className="font-medium">Diff strategy</span>
<span className="font-medium">{t("settings:advanced.diff.strategy.label")}</span>
<select
value={
experiments[EXPERIMENT_IDS.DIFF_STRATEGY]
Expand All @@ -101,25 +104,31 @@ export const AdvancedSettings = ({
}
}}
className="p-2 rounded w-full bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border outline-none focus:border-vscode-focusBorder">
<option value="standard">Standard (Single block)</option>
<option value="multiBlock">Experimental: Multi-block diff</option>
<option value="unified">Experimental: Unified diff</option>
<option value="standard">
{t("settings:advanced.diff.strategy.options.standard")}
</option>
<option value="multiBlock">
{t("settings:advanced.diff.strategy.options.multiBlock")}
</option>
<option value="unified">
{t("settings:advanced.diff.strategy.options.unified")}
</option>
</select>
</div>

{/* Description for selected strategy */}
<p className="text-vscode-descriptionForeground text-sm mt-1">
{!experiments[EXPERIMENT_IDS.DIFF_STRATEGY] &&
!experiments[EXPERIMENT_IDS.MULTI_SEARCH_AND_REPLACE] &&
"Standard diff strategy applies changes to a single code block at a time."}
t("settings:advanced.diff.strategy.descriptions.standard")}
{experiments[EXPERIMENT_IDS.DIFF_STRATEGY] &&
"Unified diff strategy takes multiple approaches to applying diffs and chooses the best approach."}
t("settings:advanced.diff.strategy.descriptions.unified")}
{experiments[EXPERIMENT_IDS.MULTI_SEARCH_AND_REPLACE] &&
"Multi-block diff strategy allows updating multiple code blocks in a file in one request."}
t("settings:advanced.diff.strategy.descriptions.multiBlock")}
</p>

{/* Match precision slider */}
<span className="font-medium mt-3">Match precision</span>
<span className="font-medium mt-3">{t("settings:advanced.diff.matchPrecision.label")}</span>
<div className="flex items-center gap-2">
<input
type="range"
Expand All @@ -137,9 +146,7 @@ export const AdvancedSettings = ({
</span>
</div>
<p className="text-vscode-descriptionForeground text-sm mt-0">
This slider controls how precisely code sections must match when applying diffs. Lower
values allow more flexible matching but increase the risk of incorrect replacements. Use
values below 100% with extreme caution.
{t("settings:advanced.diff.matchPrecision.description")}
</p>
</div>
)}
Expand Down
50 changes: 33 additions & 17 deletions webview-ui/src/components/settings/ApiConfigManager.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useRef, useState } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
import { Dropdown } from "vscrui"
import type { DropdownOption } from "vscrui"
Expand All @@ -23,6 +24,7 @@ const ApiConfigManager = ({
onRenameConfig,
onUpsertConfig,
}: ApiConfigManagerProps) => {
const { t } = useAppTranslation()
const [isRenaming, setIsRenaming] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [inputValue, setInputValue] = useState("")
Expand All @@ -33,18 +35,18 @@ const ApiConfigManager = ({

const validateName = (name: string, isNewProfile: boolean): string | null => {
const trimmed = name.trim()
if (!trimmed) return "Name cannot be empty"
if (!trimmed) return t("settings:providers.nameEmpty")

const nameExists = listApiConfigMeta?.some((config) => config.name.toLowerCase() === trimmed.toLowerCase())

// For new profiles, any existing name is invalid
if (isNewProfile && nameExists) {
return "A profile with this name already exists"
return t("settings:providers.nameExists")
}

// For rename, only block if trying to rename to a different existing profile
if (!isNewProfile && nameExists && trimmed.toLowerCase() !== currentApiConfigName?.toLowerCase()) {
return "A profile with this name already exists"
return t("settings:providers.nameExists")
}

return null
Expand Down Expand Up @@ -144,7 +146,7 @@ const ApiConfigManager = ({
return (
<div className="flex flex-col gap-1">
<label htmlFor="config-profile">
<span className="font-medium">Configuration Profile</span>
<span className="font-medium">{t("settings:providers.configProfile")}</span>
</label>

{isRenaming ? (
Expand All @@ -160,7 +162,7 @@ const ApiConfigManager = ({
setInputValue(target.target.value)
setError(null)
}}
placeholder="Enter new name"
placeholder={t("settings:providers.enterNewName")}
style={{ flexGrow: 1 }}
onKeyDown={(e: unknown) => {
const event = e as { key: string }
Expand All @@ -175,7 +177,8 @@ const ApiConfigManager = ({
appearance="icon"
disabled={!inputValue.trim()}
onClick={handleSave}
title="Save"
title={t("settings:common.save")}
data-testid="save-rename-button"
style={{
padding: 0,
margin: 0,
Expand All @@ -188,7 +191,8 @@ const ApiConfigManager = ({
<VSCodeButton
appearance="icon"
onClick={handleCancel}
title="Cancel"
title={t("settings:common.cancel")}
data-testid="cancel-rename-button"
style={{
padding: 0,
margin: 0,
Expand Down Expand Up @@ -224,7 +228,8 @@ const ApiConfigManager = ({
<VSCodeButton
appearance="icon"
onClick={handleAdd}
title="Add profile"
title={t("settings:providers.addProfile")}
data-testid="add-profile-button"
style={{
padding: 0,
margin: 0,
Expand All @@ -239,7 +244,8 @@ const ApiConfigManager = ({
<VSCodeButton
appearance="icon"
onClick={handleStartRename}
title="Rename profile"
title={t("settings:providers.renameProfile")}
data-testid="rename-profile-button"
style={{
padding: 0,
margin: 0,
Expand All @@ -252,7 +258,12 @@ const ApiConfigManager = ({
<VSCodeButton
appearance="icon"
onClick={handleDelete}
title={isOnlyProfile ? "Cannot delete the only profile" : "Delete profile"}
title={
isOnlyProfile
? t("settings:providers.cannotDeleteOnlyProfile")
: t("settings:providers.deleteProfile")
}
data-testid="delete-profile-button"
disabled={isOnlyProfile}
style={{
padding: 0,
Expand All @@ -272,7 +283,7 @@ const ApiConfigManager = ({
margin: "5px 0 12px",
color: "var(--vscode-descriptionForeground)",
}}>
Save different API configurations to quickly switch between providers and settings.
{t("settings:providers.description")}
</p>
</>
)}
Expand All @@ -290,7 +301,7 @@ const ApiConfigManager = ({
}}
aria-labelledby="new-profile-title">
<DialogContent className="p-4 max-w-sm">
<DialogTitle>New Configuration Profile</DialogTitle>
<DialogTitle>{t("settings:providers.newProfile")}</DialogTitle>
<Input
ref={newProfileInputRef}
value={newProfileName}
Expand All @@ -299,7 +310,8 @@ const ApiConfigManager = ({
setNewProfileName(target.target.value)
setError(null)
}}
placeholder="Enter profile name"
placeholder={t("settings:providers.enterProfileName")}
data-testid="new-profile-input"
style={{ width: "100%" }}
onKeyDown={(e: unknown) => {
const event = e as { key: string }
Expand All @@ -316,11 +328,15 @@ const ApiConfigManager = ({
</p>
)}
<div className="flex justify-end gap-2 mt-4">
<Button variant="secondary" onClick={resetCreateState}>
Cancel
<Button variant="secondary" onClick={resetCreateState} data-testid="cancel-new-profile-button">
{t("settings:common.cancel")}
</Button>
<Button variant="default" disabled={!newProfileName.trim()} onClick={handleNewProfileSave}>
Create Profile
<Button
variant="default"
disabled={!newProfileName.trim()}
onClick={handleNewProfileSave}
data-testid="create-profile-button">
{t("settings:providers.createProfile")}
</Button>
</div>
</DialogContent>
Expand Down
Loading