Skip to content
Closed
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
93 changes: 70 additions & 23 deletions webview-ui/src/components/chat/ModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { IconButton } from "./IconButton"
import { vscode } from "@/utils/vscode"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Mode, getAllModes } from "@roo/modes"
import { Mode, getAllModes, isCustomMode } from "@roo/modes"
import { ModeConfig, CustomModePrompts } from "@roo-code/types"
import { telemetryClient } from "@/utils/TelemetryClient"
import { TelemetryEventName } from "@roo-code/types"
Expand All @@ -16,6 +16,32 @@ import { Fzf } from "fzf"
// Minimum number of modes required to show search functionality
const SEARCH_THRESHOLD = 6

// Helper function to get the source of a custom mode
function getModeSource(mode: ModeConfig, customModes?: ModeConfig[]): "global" | "project" | null {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider defining a type or enum for mode source values instead of using string literals "global" | "project" throughout the code. This would improve type safety and maintainability. For example:

if (!isCustomMode(mode.slug, customModes)) {
return null // Built-in mode, no source indicator needed
}

// Find the mode in customModes to get its source
const customMode = customModes?.find((m) => m.slug === mode.slug)
return customMode?.source || "global" // Default to global if source is not specified
}

// Helper function to get the display text for mode source
function getSourceDisplayText(
source: "global" | "project" | null,
t: (key: string) => string,
short: boolean = false,
): string {
if (!source) return ""

if (short) {
return source === "global" ? t("chat:modeSelector.globalShort") : t("chat:modeSelector.projectShort")
}

return source === "global" ? t("chat:modeSelector.global") : t("chat:modeSelector.project")
}

interface ModeSelectorProps {
value: Mode
onChange: (value: Mode) => void
Expand Down Expand Up @@ -159,6 +185,10 @@ export const ModeSelector = ({
// Combine instruction text for tooltip
const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`

// Get source indicator for selected mode
const selectedModeSource = selectedMode ? getModeSource(selectedMode, customModes) : null
const selectedModeSourceText = getSourceDisplayText(selectedModeSource, t, false)

const trigger = (
<PopoverTrigger
disabled={disabled}
Expand All @@ -176,7 +206,12 @@ export const ModeSelector = ({
: null,
)}>
<ChevronUp className="pointer-events-none opacity-80 flex-shrink-0 size-3" />
<span className="truncate">{selectedMode?.name || ""}</span>
<span className="truncate">
{selectedMode?.name || ""}
{selectedModeSourceText && (
<span className="text-vscode-descriptionForeground ml-1">({selectedModeSourceText})</span>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source indicator rendering logic is duplicated between the trigger button (lines 211-213) and dropdown items (lines 282-286). Consider extracting this into a small component for better reusability and consistency. For example:

)}
</span>
</PopoverTrigger>
)

Expand Down Expand Up @@ -225,29 +260,41 @@ export const ModeSelector = ({
</div>
) : (
<div className="py-1">
{filteredModes.map((mode) => (
<div
key={mode.slug}
onClick={() => handleSelect(mode.slug)}
className={cn(
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
"hover:bg-vscode-list-hoverBackground",
mode.slug === value
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
: "",
)}
data-testid="mode-selector-item">
<div className="flex-1 min-w-0">
<div className="font-bold truncate">{mode.name}</div>
{mode.description && (
<div className="text-xs text-vscode-descriptionForeground truncate">
{mode.description}
</div>
{filteredModes.map((mode) => {
const modeSource = getModeSource(mode, customModes)
const sourceShortText = getSourceDisplayText(modeSource, t, true)

return (
<div
key={mode.slug}
onClick={() => handleSelect(mode.slug)}
className={cn(
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
"hover:bg-vscode-list-hoverBackground",
mode.slug === value
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
: "",
)}
data-testid="mode-selector-item">
<div className="flex-1 min-w-0">
<div className="font-bold truncate flex items-center gap-1.5">
{mode.name}
{sourceShortText && (
<span className="text-xs text-vscode-descriptionForeground font-normal">
({sourceShortText})
</span>
)}
</div>
{mode.description && (
<div className="text-xs text-vscode-descriptionForeground truncate">
{mode.description}
</div>
)}
</div>
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
</div>
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
</div>
))}
)
})}
</div>
)}
</div>
Expand Down
136 changes: 136 additions & 0 deletions webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,140 @@ describe("ModeSelector", () => {
const infoIcon = document.querySelector(".codicon-info")
expect(infoIcon).toBeInTheDocument()
})

test("shows source indicators for custom modes", () => {
// Set up mock to return custom modes with source
mockModes = [
{
slug: "custom-global",
name: "Custom Global Mode",
description: "A global custom mode",
roleDefinition: "Role definition",
groups: ["read", "edit"] as const,
source: "global",
},
{
slug: "custom-project",
name: "Custom Project Mode",
description: "A project custom mode",
roleDefinition: "Role definition",
groups: ["read", "edit"] as const,
source: "project",
},
{
slug: "code",
name: "Code Mode",
description: "Built-in code mode",
roleDefinition: "Role definition",
groups: ["read", "edit"] as const,
},
]

const customModes: ModeConfig[] = [
{
slug: "custom-global",
name: "Custom Global Mode",
description: "A global custom mode",
roleDefinition: "Role definition",
groups: ["read", "edit"],
source: "global",
},
{
slug: "custom-project",
name: "Custom Project Mode",
description: "A project custom mode",
roleDefinition: "Role definition",
groups: ["read", "edit"],
source: "project",
},
]

render(
<ModeSelector
value={"custom-global" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
customModes={customModes}
/>,
)

// Click to open the popover
fireEvent.click(screen.getByTestId("mode-selector-trigger"))

// Check that custom modes show source indicators in dropdown
const modeItems = screen.getAllByTestId("mode-selector-item")

// Find the custom modes in the dropdown
const globalModeItem = modeItems.find((item) => item.textContent?.includes("Custom Global Mode"))
const projectModeItem = modeItems.find((item) => item.textContent?.includes("Custom Project Mode"))
const builtinModeItem = modeItems.find((item) => item.textContent?.includes("Code Mode"))

// Custom modes should show source indicators
expect(globalModeItem?.textContent).toContain("(chat:modeSelector.globalShort)")
expect(projectModeItem?.textContent).toContain("(chat:modeSelector.projectShort)")

// Built-in mode should not show source indicator
expect(builtinModeItem?.textContent).not.toContain("(chat:modeSelector.globalShort)")
expect(builtinModeItem?.textContent).not.toContain("(chat:modeSelector.projectShort)")
})

test("shows source indicator in selected mode button", () => {
// Set up mock to return custom modes with source
mockModes = [
{
slug: "custom-project",
name: "Custom Project Mode",
description: "A project custom mode",
roleDefinition: "Role definition",
groups: ["read", "edit"] as const,
source: "project",
},
]

const customModes: ModeConfig[] = [
{
slug: "custom-project",
name: "Custom Project Mode",
description: "A project custom mode",
roleDefinition: "Role definition",
groups: ["read", "edit"],
source: "project",
},
]

render(
<ModeSelector
value={"custom-project" as Mode}
onChange={vi.fn()}
modeShortcutText="Ctrl+M"
customModes={customModes}
/>,
)

// Check that the trigger button shows the source indicator
const trigger = screen.getByTestId("mode-selector-trigger")
expect(trigger.textContent).toContain("Custom Project Mode")
expect(trigger.textContent).toContain("(chat:modeSelector.project)")
})

test("does not show source indicator for built-in modes", () => {
// Set up mock to return only built-in modes
mockModes = [
{
slug: "code",
name: "Code Mode",
description: "Built-in code mode",
roleDefinition: "Role definition",
groups: ["read", "edit"] as const,
},
]

render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" customModes={[]} />)

// Check that the trigger button does not show source indicator
const trigger = screen.getByTestId("mode-selector-trigger")
expect(trigger.textContent).toContain("Code Mode")
expect(trigger.textContent).not.toContain("(Global)")
expect(trigger.textContent).not.toContain("(Project)")
})
})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding test cases for edge scenarios:

  • What happens when a custom mode has no source field (should default to "global" as per the implementation)
  • Behavior when customModes array is undefined or null
  • Ensuring the source indicator styling works correctly in both light and dark themes

These tests would help ensure robustness of the feature.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/ca/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/de/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@
"settings": "Mode Settings",
"description": "Specialized personas that tailor Roo's behavior.",
"searchPlaceholder": "Search modes...",
"noResults": "No results found"
"noResults": "No results found",
"global": "Global",
"project": "Project",
"globalShort": "G",
"projectShort": "P"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR only adds translations for 4 languages (en, fr, es, de) but the codebase supports 18 languages total. The missing translations for the other 14 languages (ca, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW) could cause the mode source indicators to display as untranslated keys like "chat:modeSelector.global" for users of those languages. Could we add translations for all supported languages to ensure a consistent experience for all users?

},
"enhancePromptDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
"addImages": "Add images to message",
Expand Down
6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/es/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/fr/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/hi/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/id/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/it/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion webview-ui/src/i18n/locales/ja/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading