-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat: add visual indicators for global/project custom modes in mode selector #6504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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 { | ||
| 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 | ||
|
|
@@ -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} | ||
|
|
@@ -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> | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
| ) | ||
|
|
||
|
|
@@ -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> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)") | ||
| }) | ||
| }) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider adding test cases for edge scenarios:
These tests would help ensure robustness of the feature. |
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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: