feat: add mode display indicators on task cards

- Create reusable ModeBadge component that displays mode name from slug
- Update TaskItemHeader to show mode badge next to timestamp in history view
- Update TaskHeader to show mode badge in main chat view task header
- Handle cases where mode is undefined (no badge displayed)
- Handle deleted custom modes gracefully
- Implement text truncation for long mode names with CSS
- Add comprehensive unit tests for ModeBadge component
- Use existing Badge component with outline variant for consistent styling

Fixes #6493
This commit is contained in:
Roo Code 2025-07-31 16:39:50 +00:00
parent 74672fafcb
commit 2905191390
4 changed files with 230 additions and 4 deletions

View file

@ -15,6 +15,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import Thumbnails from "../common/Thumbnails"
import ModeBadge from "../common/ModeBadge"
import { TaskActions } from "./TaskActions"
import { ShareButton } from "./ShareButton"
@ -91,10 +92,13 @@ const TaskHeader = ({
<span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
</div>
<div className="ml-1.5 whitespace-nowrap overflow-hidden text-ellipsis grow min-w-0">
<span className="font-bold">
{t("chat:task.title")}
{!isTaskExpanded && ":"}
</span>
<div className="flex items-center gap-2">
<span className="font-bold">
{t("chat:task.title")}
{!isTaskExpanded && ":"}
</span>
{currentTaskItem?.mode && <ModeBadge modeSlug={currentTaskItem.mode} />}
</div>
{!isTaskExpanded && (
<span className="ml-1">
<Mention text={task.text} />

View file

@ -0,0 +1,51 @@
import React from "react"
import { getModeBySlug } from "../../../../src/shared/modes"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { Badge } from "@/components/ui/badge"
import { StandardTooltip } from "@/components/ui/standard-tooltip"
export interface ModeBadgeProps {
modeSlug: string
className?: string
}
/**
* ModeBadge component displays a badge showing the mode name for a given mode slug.
* It handles cases where the mode is undefined or deleted gracefully.
* For long mode names, it truncates with ellipsis and shows full name in tooltip.
*/
const ModeBadge: React.FC<ModeBadgeProps> = ({ modeSlug, className }) => {
const { customModes } = useExtensionState()
// Get mode details using the existing getModeBySlug function
const mode = getModeBySlug(modeSlug, customModes)
// If mode is not found, don't render anything
if (!mode) {
return null
}
// Extract just the mode name (without emoji if present)
// Mode names can be like "💻 Code" or just "Code"
const modeName = mode.name
// For very long mode names, we'll let CSS handle truncation
const badgeContent = (
<Badge
variant="outline"
className={`text-xs font-medium max-w-24 truncate ${className || ""}`}
title={modeName} // Fallback tooltip
>
{modeName}
</Badge>
)
// If the mode name is longer than ~20 characters, wrap with tooltip
if (modeName.length > 20) {
return <StandardTooltip content={modeName}>{badgeContent}</StandardTooltip>
}
return badgeContent
}
export default ModeBadge

View file

@ -0,0 +1,169 @@
// npx vitest run src/components/common/__tests__/ModeBadge.spec.tsx
import { render, screen } from "@/utils/test-utils"
import { DEFAULT_MODES, type ModeConfig } from "@roo-code/types"
import ModeBadge from "../ModeBadge"
// Mock the shared modes module
vi.mock("../../../../../src/shared/modes", () => ({
getModeBySlug: vi.fn((slug: string, customModes?: any[]) => {
// First check custom modes
const customMode = customModes?.find((mode) => mode.slug === slug)
if (customMode) {
return customMode
}
// Then check built-in modes
return DEFAULT_MODES.find((mode) => mode.slug === slug)
}),
}))
// Mock the extension state context
const mockExtensionState = {
customModes: [] as ModeConfig[],
}
vi.mock("../../../context/ExtensionStateContext", () => ({
useExtensionState: () => mockExtensionState,
}))
describe("ModeBadge", () => {
beforeEach(() => {
// Reset custom modes before each test
mockExtensionState.customModes = []
})
it("renders mode badge for built-in mode", () => {
render(<ModeBadge modeSlug="code" />)
const badge = screen.getByText("💻 Code")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass("border-vscode-input-border")
})
it("renders mode badge for architect mode", () => {
render(<ModeBadge modeSlug="architect" />)
expect(screen.getByText("🏗️ Architect")).toBeInTheDocument()
})
it("renders mode badge for ask mode", () => {
render(<ModeBadge modeSlug="ask" />)
expect(screen.getByText("❓ Ask")).toBeInTheDocument()
})
it("renders mode badge for debug mode", () => {
render(<ModeBadge modeSlug="debug" />)
expect(screen.getByText("🪲 Debug")).toBeInTheDocument()
})
it("renders mode badge for orchestrator mode", () => {
render(<ModeBadge modeSlug="orchestrator" />)
expect(screen.getByText("🪃 Orchestrator")).toBeInTheDocument()
})
it("renders mode badge for custom mode", () => {
// Add a custom mode to the mock state
mockExtensionState.customModes = [
{
slug: "custom-test",
name: "🧪 Test Mode",
roleDefinition: "Test role",
groups: ["read"],
},
]
render(<ModeBadge modeSlug="custom-test" />)
expect(screen.getByText("🧪 Test Mode")).toBeInTheDocument()
})
it("renders mode badge for custom mode without emoji", () => {
// Add a custom mode without emoji
mockExtensionState.customModes = [
{
slug: "plain-mode",
name: "Plain Mode",
roleDefinition: "Plain role",
groups: ["read"],
},
]
render(<ModeBadge modeSlug="plain-mode" />)
expect(screen.getByText("Plain Mode")).toBeInTheDocument()
})
it("returns null for undefined mode", () => {
const { container } = render(<ModeBadge modeSlug="non-existent-mode" />)
expect(container.firstChild).toBeNull()
})
it("returns null for deleted custom mode", () => {
// Simulate a mode that was deleted but still referenced in history
render(<ModeBadge modeSlug="deleted-custom-mode" />)
const { container } = render(<ModeBadge modeSlug="deleted-custom-mode" />)
expect(container.firstChild).toBeNull()
})
it("applies custom className", () => {
render(<ModeBadge modeSlug="code" className="custom-class" />)
const badge = screen.getByText("💻 Code")
expect(badge).toHaveClass("custom-class")
})
it("truncates long mode names with CSS", () => {
// Add a custom mode with a very long name
mockExtensionState.customModes = [
{
slug: "very-long-mode",
name: "This is a very long mode name that should be truncated",
roleDefinition: "Long role",
groups: ["read"],
},
]
render(<ModeBadge modeSlug="very-long-mode" />)
const badge = screen.getByText("This is a very long mode name that should be truncated")
expect(badge).toHaveClass("max-w-24", "truncate")
})
it("has proper title attribute for accessibility", () => {
render(<ModeBadge modeSlug="code" />)
const badge = screen.getByText("💻 Code")
expect(badge).toHaveAttribute("title", "💻 Code")
})
it("uses outline variant for consistent styling", () => {
render(<ModeBadge modeSlug="code" />)
const badge = screen.getByText("💻 Code")
expect(badge).toHaveClass("border-vscode-input-border")
})
it("handles custom mode overriding built-in mode", () => {
// Add a custom mode that overrides a built-in mode
mockExtensionState.customModes = [
{
slug: "code",
name: "🔧 Custom Code",
roleDefinition: "Custom code role",
groups: ["read", "edit"],
},
]
render(<ModeBadge modeSlug="code" />)
// Should show the custom mode name, not the built-in one
expect(screen.getByText("🔧 Custom Code")).toBeInTheDocument()
expect(screen.queryByText("💻 Code")).not.toBeInTheDocument()
})
})

View file

@ -3,6 +3,7 @@ import type { HistoryItem } from "@roo-code/types"
import { formatDate } from "@/utils/format"
import { DeleteButton } from "./DeleteButton"
import { cn } from "@/lib/utils"
import ModeBadge from "../common/ModeBadge"
export interface TaskItemHeaderProps {
item: HistoryItem
@ -22,6 +23,7 @@ const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, isSelectionMode,
<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
{formatDate(item.ts)}
</span>
{item.mode && <ModeBadge modeSlug={item.mode} />}
</div>
{/* Action Buttons */}