Skip to content

Commit 9a7a2a7

Browse files
committed
feat: add visual indicators for global/project custom modes (#6502)
1 parent 3803c29 commit 9a7a2a7

File tree

20 files changed

+264
-24
lines changed

20 files changed

+264
-24
lines changed

webview-ui/src/components/chat/ModeSelector.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ export const ModeSelector = ({
4646
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
4747
const { t } = useAppTranslation()
4848

49+
// Helper to determine if a mode is custom and get its source
50+
const getModeSource = (mode: ModeConfig): string | null => {
51+
const isCustom = customModes?.some((m) => m.slug === mode.slug)
52+
if (!isCustom) return null
53+
return mode.source || "global" // Default to global if source is undefined
54+
}
55+
56+
// Helper to get display text for source
57+
const getSourceDisplayText = (source: string | null, isShort: boolean = false): string => {
58+
if (!source) return ""
59+
if (isShort) {
60+
return source === "global" ? t("chat:modeSelector.globalShort") : t("chat:modeSelector.projectShort")
61+
}
62+
return source === "global" ? t("chat:modeSelector.global") : t("chat:modeSelector.project")
63+
}
64+
4965
const trackModeSelectorOpened = React.useCallback(() => {
5066
// Track telemetry every time the mode selector is opened
5167
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
@@ -159,6 +175,13 @@ export const ModeSelector = ({
159175
// Combine instruction text for tooltip
160176
const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`
161177

178+
// Helper function to render source indicator
179+
const renderSourceIndicator = (mode: ModeConfig, isShort: boolean = false) => {
180+
const source = getModeSource(mode)
181+
if (!source) return null
182+
return <span className="ml-1 text-vscode-descriptionForeground">({getSourceDisplayText(source, isShort)})</span>
183+
}
184+
162185
const trigger = (
163186
<PopoverTrigger
164187
disabled={disabled}
@@ -176,7 +199,10 @@ export const ModeSelector = ({
176199
: null,
177200
)}>
178201
<ChevronUp className="pointer-events-none opacity-80 flex-shrink-0 size-3" />
179-
<span className="truncate">{selectedMode?.name || ""}</span>
202+
<span className="truncate">
203+
{selectedMode?.name || ""}
204+
{selectedMode && renderSourceIndicator(selectedMode, false)}
205+
</span>
180206
</PopoverTrigger>
181207
)
182208

@@ -238,7 +264,10 @@ export const ModeSelector = ({
238264
)}
239265
data-testid="mode-selector-item">
240266
<div className="flex-1 min-w-0">
241-
<div className="font-bold truncate">{mode.name}</div>
267+
<div className="font-bold truncate">
268+
{mode.name}
269+
{renderSourceIndicator(mode, true)}
270+
</div>
242271
{mode.description && (
243272
<div className="text-xs text-vscode-descriptionForeground truncate">
244273
{mode.description}

webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,21 @@ vi.mock("@/context/ExtensionStateContext", () => ({
2121

2222
vi.mock("@/i18n/TranslationContext", () => ({
2323
useAppTranslation: () => ({
24-
t: (key: string) => key,
24+
t: (key: string) => {
25+
const translations: Record<string, string> = {
26+
"chat:modeSelector.global": "Global",
27+
"chat:modeSelector.project": "Project",
28+
"chat:modeSelector.globalShort": "G",
29+
"chat:modeSelector.projectShort": "P",
30+
"chat:modeSelector.description": "Select a mode to change how the assistant responds.",
31+
"chat:modeSelector.searchPlaceholder": "Search modes...",
32+
"chat:modeSelector.noResults": "No modes found",
33+
"chat:modeSelector.marketplace": "Browse Marketplace",
34+
"chat:modeSelector.settings": "Mode Settings",
35+
"chat:modeSelector.title": "Modes",
36+
}
37+
return translations[key] || key
38+
},
2539
}),
2640
}))
2741

@@ -93,7 +107,7 @@ describe("ModeSelector", () => {
93107
expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
94108

95109
// Info icon should be visible
96-
expect(screen.getByText("chat:modeSelector.title")).toBeInTheDocument()
110+
expect(screen.getByText("Modes")).toBeInTheDocument()
97111
const infoIcon = document.querySelector(".codicon-info")
98112
expect(infoIcon).toBeInTheDocument()
99113
})
@@ -117,7 +131,7 @@ describe("ModeSelector", () => {
117131
expect(screen.queryByTestId("mode-search-input")).not.toBeInTheDocument()
118132

119133
// Info blurb should be visible
120-
expect(screen.getByText(/chat:modeSelector.description/)).toBeInTheDocument()
134+
expect(screen.getByText(/Select a mode to change how the assistant responds./)).toBeInTheDocument()
121135

122136
// Info icon should NOT be visible
123137
const infoIcon = document.querySelector(".codicon-info")
@@ -169,7 +183,7 @@ describe("ModeSelector", () => {
169183
expect(screen.queryByTestId("mode-search-input")).not.toBeInTheDocument()
170184

171185
// Info blurb should be visible instead
172-
expect(screen.getByText(/chat:modeSelector.description/)).toBeInTheDocument()
186+
expect(screen.getByText(/Select a mode to change how the assistant responds./)).toBeInTheDocument()
173187

174188
// Info icon should NOT be visible
175189
const infoIcon = document.querySelector(".codicon-info")
@@ -199,4 +213,129 @@ describe("ModeSelector", () => {
199213
const infoIcon = document.querySelector(".codicon-info")
200214
expect(infoIcon).toBeInTheDocument()
201215
})
216+
217+
test("shows source indicator for custom modes", () => {
218+
const customModesWithSource: ModeConfig[] = [
219+
{
220+
slug: "custom-global",
221+
name: "Custom Global Mode",
222+
roleDefinition: "Role",
223+
groups: ["read"] as ModeConfig["groups"],
224+
source: "global",
225+
},
226+
{
227+
slug: "custom-project",
228+
name: "Custom Project Mode",
229+
roleDefinition: "Role",
230+
groups: ["read"] as ModeConfig["groups"],
231+
source: "project",
232+
},
233+
]
234+
235+
// Set up mock to return custom modes
236+
mockModes = [
237+
...customModesWithSource,
238+
{
239+
slug: "code",
240+
name: "Code",
241+
roleDefinition: "Role",
242+
groups: ["read"] as ModeConfig["groups"],
243+
},
244+
]
245+
246+
render(
247+
<ModeSelector
248+
value={"custom-global" as Mode}
249+
onChange={vi.fn()}
250+
modeShortcutText="Ctrl+M"
251+
customModes={customModesWithSource}
252+
/>,
253+
)
254+
255+
// Check selected mode shows full indicator
256+
const trigger = screen.getByTestId("mode-selector-trigger")
257+
expect(trigger).toHaveTextContent("Custom Global Mode")
258+
expect(trigger).toHaveTextContent("(Global)")
259+
260+
// Open dropdown
261+
fireEvent.click(trigger)
262+
263+
// Check dropdown shows short indicators
264+
const items = screen.getAllByTestId("mode-selector-item")
265+
const globalItem = items.find((item) => item.textContent?.includes("Custom Global Mode"))
266+
const projectItem = items.find((item) => item.textContent?.includes("Custom Project Mode"))
267+
268+
expect(globalItem).toHaveTextContent("(G)")
269+
expect(projectItem).toHaveTextContent("(P)")
270+
})
271+
272+
test("shows no indicator for built-in modes", () => {
273+
// Set up mock to return only built-in modes
274+
mockModes = [
275+
{
276+
slug: "code",
277+
name: "Code",
278+
roleDefinition: "Role",
279+
groups: ["read"] as ModeConfig["groups"],
280+
},
281+
{
282+
slug: "architect",
283+
name: "Architect",
284+
roleDefinition: "Role",
285+
groups: ["read"] as ModeConfig["groups"],
286+
},
287+
]
288+
289+
render(<ModeSelector value={"code" as Mode} onChange={vi.fn()} modeShortcutText="Ctrl+M" />)
290+
291+
const trigger = screen.getByTestId("mode-selector-trigger")
292+
expect(trigger).toHaveTextContent("Code")
293+
expect(trigger).not.toHaveTextContent("(")
294+
295+
// Open dropdown
296+
fireEvent.click(trigger)
297+
298+
// Check that no items have indicators
299+
const items = screen.getAllByTestId("mode-selector-item")
300+
items.forEach((item) => {
301+
expect(item).not.toHaveTextContent("(Global")
302+
expect(item).not.toHaveTextContent("(Project")
303+
})
304+
})
305+
306+
test("defaults to global for custom modes without source", () => {
307+
const customModesNoSource: ModeConfig[] = [
308+
{
309+
slug: "custom-old",
310+
name: "Old Custom Mode",
311+
roleDefinition: "Role",
312+
groups: ["read"] as ModeConfig["groups"],
313+
// No source field
314+
},
315+
]
316+
317+
// Set up mock to include the custom mode
318+
mockModes = [
319+
...customModesNoSource,
320+
{
321+
slug: "code",
322+
name: "Code",
323+
roleDefinition: "Role",
324+
groups: ["read"] as ModeConfig["groups"],
325+
},
326+
]
327+
328+
render(
329+
<ModeSelector
330+
value={"custom-old" as Mode}
331+
onChange={vi.fn()}
332+
modeShortcutText="Ctrl+M"
333+
customModes={customModesNoSource}
334+
/>,
335+
)
336+
337+
const trigger = screen.getByTestId("mode-selector-trigger")
338+
expect(trigger).toHaveTextContent("Old Custom Mode")
339+
expect(trigger).toHaveTextContent("(Global)")
340+
})
202341
})

webview-ui/src/i18n/locales/ca/chat.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/de/chat.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/en/chat.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@
120120
"settings": "Mode Settings",
121121
"description": "Specialized personas that tailor Roo's behavior.",
122122
"searchPlaceholder": "Search modes...",
123-
"noResults": "No results found"
123+
"noResults": "No results found",
124+
"global": "Global",
125+
"project": "Project",
126+
"globalShort": "G",
127+
"projectShort": "P"
124128
},
125129
"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.",
126130
"addImages": "Add images to message",

webview-ui/src/i18n/locales/es/chat.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/fr/chat.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/hi/chat.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/id/chat.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/it/chat.json

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)