feat: add setting to toggle task titles

This commit is contained in:
Jakob Malmo 2025-09-29 19:54:48 +02:00 committed by Roo Code
parent 1524d66ee4
commit f14b9683dd
15 changed files with 147 additions and 37 deletions

View file

@ -193,6 +193,7 @@ export const globalSettingsSchema = z.object({
* @default "send"
*/
enterBehavior: z.enum(["send", "newline"]).optional(),
taskTitlesEnabled: z.boolean().optional(),
profileThresholds: z.record(z.string(), z.number()).optional(),
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),

View file

@ -262,6 +262,7 @@ export type ExtensionState = Pick<
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "taskTitlesEnabled"
> & {
version: string
clineMessages: ClineMessage[]
@ -302,6 +303,7 @@ export type ExtensionState = Pick<
renderContext: "sidebar" | "editor"
settingsImportedAt?: number
historyPreviewCollapsed?: boolean
taskTitlesEnabled?: boolean
cloudUserInfo: CloudUserInfo | null
cloudIsAuthenticated: boolean

View file

@ -2003,6 +2003,7 @@ export class ClineProvider
historyPreviewCollapsed,
reasoningBlockCollapsed,
enterBehavior,
taskTitlesEnabled,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
@ -2093,6 +2094,7 @@ export class ClineProvider
taskHistory: (taskHistory || [])
.filter((item: HistoryItem) => item.ts && item.task)
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
taskTitlesEnabled: taskTitlesEnabled ?? false,
soundEnabled: soundEnabled ?? false,
ttsEnabled: ttsEnabled ?? false,
ttsSpeed: ttsSpeed ?? 1.0,
@ -2409,6 +2411,7 @@ export class ClineProvider
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
enterBehavior: stateValues.enterBehavior ?? "send",
taskTitlesEnabled: stateValues.taskTitlesEnabled ?? false,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,

View file

@ -1668,7 +1668,6 @@ export const webviewMessageHandler = async (
await updateGlobalState("hasOpenedModeSelector", message.bool ?? true)
await provider.postStateToWebview()
break
case "toggleApiConfigPin":
if (message.text) {
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}

View file

@ -376,7 +376,9 @@ const TaskHeader = ({
<span className="font-bold">{t("chat:task.title")}</span>
{renderTitleAction()}
</div>
{expandedTitleContent ? <div className="min-w-0">{expandedTitleContent}</div> : null}
{expandedTitleContent ? (
<div className="min-w-0">{expandedTitleContent}</div>
) : null}
</div>
) : (
<div className="flex items-center gap-2 min-w-0">

View file

@ -49,6 +49,7 @@ let mockExtensionState: {
apiConfiguration: ProviderSettings
currentTaskItem: { id: string } | null
clineMessages: any[]
taskTitlesEnabled: boolean
} = {
apiConfiguration: {
apiProvider: "anthropic",
@ -57,6 +58,7 @@ let mockExtensionState: {
} as ProviderSettings,
currentTaskItem: { id: "test-task-id" },
clineMessages: [],
taskTitlesEnabled: true,
}
// Mock the ExtensionStateContext
@ -111,6 +113,7 @@ describe("TaskHeader", () => {
} as ProviderSettings,
currentTaskItem: { id: "test-task-id" },
clineMessages: [],
taskTitlesEnabled: true,
}
})
const defaultProps: TaskHeaderProps = {
@ -221,6 +224,14 @@ describe("TaskHeader", () => {
})
})
it("hides title controls when task titles are disabled", () => {
mockExtensionState.taskTitlesEnabled = false
renderTaskHeader()
expect(screen.queryByTestId("task-title-edit-button")).not.toBeInTheDocument()
expect(screen.queryByTestId("task-title-display")).not.toBeInTheDocument()
})
describe("DismissibleUpsell behavior", () => {
beforeEach(() => {
vi.useFakeTimers()
@ -233,6 +244,7 @@ describe("TaskHeader", () => {
} as ProviderSettings,
currentTaskItem: { id: "test-task-id" },
clineMessages: [],
taskTitlesEnabled: true,
}
})

View file

@ -4,6 +4,7 @@ import type { HistoryItem } from "@roo-code/types"
import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { useExtensionState } from "@/context/ExtensionStateContext"
import TaskItemFooter from "./TaskItemFooter"
@ -33,6 +34,7 @@ const TaskItem = ({
onDelete,
className,
}: TaskItemProps) => {
const { taskTitlesEnabled = false } = useExtensionState()
const handleClick = () => {
if (isSelectionMode && onToggleSelection) {
onToggleSelection(item.id, !isSelected)
@ -42,6 +44,9 @@ const TaskItem = ({
}
const isCompact = variant === "compact"
const showTitle = taskTitlesEnabled && Boolean(item.title?.trim())
const displayHighlight = showTitle && item.titleHighlight ? item.titleHighlight : item.highlight
const displayText = showTitle && item.title ? item.title : item.task
return (
<div
@ -70,37 +75,24 @@ const TaskItem = ({
)}
<div className="flex-1 min-w-0">
{(item.title || item.titleHighlight) &&
(item.titleHighlight ? (
<div
className={cn("text-vscode-foreground font-semibold truncate mb-1", {
"text-base": !isCompact,
"text-sm": isCompact,
})}
data-testid="task-item-title"
dangerouslySetInnerHTML={{ __html: item.titleHighlight }}
/>
) : (
<div
className={cn("text-vscode-foreground font-semibold truncate mb-1", {
"text-base": !isCompact,
"text-sm": isCompact,
})}
data-testid="task-item-title">
{item.title}
</div>
))}
<div
className={cn(
"overflow-hidden whitespace-pre-wrap font-light text-vscode-foreground text-ellipsis line-clamp-3",
{
"text-base": !isCompact,
"text-sm": isCompact,
},
!isCompact && isSelectionMode ? "mb-1" : "",
)}
data-testid="task-content"
{...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}>
{item.highlight ? undefined : item.task}
data-testid="task-content">
{displayHighlight ? (
<span
className={cn(showTitle && "font-semibold")}
dangerouslySetInnerHTML={{ __html: displayHighlight }}
/>
) : (
<span className={cn(showTitle && "font-semibold")}>{displayText}</span>
)}
</div>
<TaskItemFooter

View file

@ -3,6 +3,10 @@ import { render, screen, fireEvent } from "@/utils/test-utils"
import TaskItem from "../TaskItem"
vi.mock("@src/utils/vscode")
const mockUseExtensionState = vi.hoisted(() => vi.fn(() => ({ taskTitlesEnabled: true })))
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: mockUseExtensionState,
}))
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
@ -29,6 +33,7 @@ const mockTask = {
describe("TaskItem", () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseExtensionState.mockReturnValue({ taskTitlesEnabled: true })
})
it("renders task information", () => {
@ -80,7 +85,7 @@ describe("TaskItem", () => {
expect(screen.getByTestId("export")).toBeInTheDocument()
})
it("renders title above task content when provided", () => {
it("renders title instead of task text when provided", () => {
render(
<TaskItem
item={{ ...mockTask, title: "Important task" }}
@ -91,8 +96,28 @@ describe("TaskItem", () => {
/>,
)
expect(screen.getByTestId("task-item-title")).toHaveTextContent("Important task")
expect(screen.getByTestId("task-content")).toHaveTextContent("Test task")
const content = screen.getByTestId("task-content")
expect(content).toHaveTextContent("Important task")
expect(content).not.toHaveTextContent("Test task")
expect(content.querySelector("span")?.className).toContain("font-semibold")
})
it("falls back to task text when feature disabled", () => {
mockUseExtensionState.mockReturnValue({ taskTitlesEnabled: false })
render(
<TaskItem
item={{ ...mockTask, title: "Hidden title" }}
variant="full"
isSelected={false}
onToggleSelection={vi.fn()}
isSelectionMode={false}
/>,
)
const content = screen.getByTestId("task-content")
expect(content).toHaveTextContent("Test task")
expect(content).not.toHaveTextContent("Hidden title")
expect(content.querySelector("span")?.className || "").not.toContain("font-semibold")
})
it("displays time ago information", () => {

View file

@ -59,6 +59,7 @@ describe("useTaskSearch", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: mockTaskHistory,
cwd: "/workspace/project1",
taskTitlesEnabled: true,
} as any)
})
@ -169,6 +170,23 @@ describe("useTaskSearch", () => {
expect((result.current.tasks[0] as any).titleHighlight).toBe("<mark>Build component</mark>")
})
it("ignores task titles in search when the feature is disabled", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: mockTaskHistory,
cwd: "/workspace/project1",
taskTitlesEnabled: false,
} as any)
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSearchQuery("build")
})
expect(result.current.tasks).toHaveLength(0)
})
it("automatically switches to mostRelevant when searching", () => {
const { result } = renderHook(() => useTaskSearch())

View file

@ -7,7 +7,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
export const useTaskSearch = () => {
const { taskHistory, cwd } = useExtensionState()
const { taskHistory, cwd, taskTitlesEnabled = false } = useExtensionState()
const [searchQuery, setSearchQuery] = useState("")
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
@ -33,9 +33,9 @@ export const useTaskSearch = () => {
const fzf = useMemo(() => {
return new Fzf(presentableTasks, {
selector: (item) => (item.title ? `${item.title} ${item.task}` : item.task),
selector: (item) => (taskTitlesEnabled && item.title ? `${item.title} ${item.task}` : item.task),
})
}, [presentableTasks])
}, [presentableTasks, taskTitlesEnabled])
const tasks = useMemo(() => {
let results = presentableTasks
@ -44,11 +44,14 @@ export const useTaskSearch = () => {
const searchResults = fzf.find(searchQuery)
results = searchResults.map((result) => {
const positions = Array.from(result.positions)
const titleLength = result.item.title ? result.item.title.length : 0
const separatorLength = titleLength > 0 ? 1 : 0
const taskOffset = titleLength + separatorLength
const titlePositions = titleLength > 0 ? positions.filter((p) => p < titleLength) : []
const taskPositions = positions.filter((p) => p >= taskOffset).map((p) => p - taskOffset)
const includeTitles = taskTitlesEnabled && !!result.item.title
const titleLength = includeTitles ? result.item.title!.length : 0
const separatorLength = includeTitles ? 1 : 0
const taskOffset = includeTitles ? titleLength + separatorLength : 0
const titlePositions = includeTitles ? positions.filter((p) => p < titleLength) : []
const taskPositions = includeTitles
? positions.filter((p) => p >= taskOffset).map((p) => p - taskOffset)
: positions
const titleHighlight =
titlePositions.length > 0 && result.item.title
@ -86,7 +89,7 @@ export const useTaskSearch = () => {
return (b.ts || 0) - (a.ts || 0)
}
})
}, [presentableTasks, searchQuery, fzf, sortOption])
}, [presentableTasks, searchQuery, fzf, sortOption, taskTitlesEnabled])
return {
tasks,

View file

@ -214,6 +214,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
taskTitlesEnabled,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -423,6 +424,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
condensingApiConfigId: condensingApiConfigId || "",
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
taskTitlesEnabled: taskTitlesEnabled ?? false,
enterBehavior: enterBehavior ?? "send",
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
@ -910,6 +912,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{/* UI Section */}
{renderTab === "ui" && (
<UISettings
taskTitlesEnabled={taskTitlesEnabled ?? false}
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
enterBehavior={enterBehavior ?? "send"}
setCachedStateField={setCachedStateField}

View file

@ -10,12 +10,14 @@ import { SearchableSetting } from "./SearchableSetting"
import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
taskTitlesEnabled: boolean
reasoningBlockCollapsed: boolean
enterBehavior: "send" | "newline"
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}
export const UISettings = ({
taskTitlesEnabled,
reasoningBlockCollapsed,
enterBehavior,
setCachedStateField,
@ -28,6 +30,12 @@ export const UISettings = ({
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
return isMac ? "⌘" : "Ctrl"
}, [])
const handleTaskTitlesEnabledChange = (value: boolean) => {
setCachedStateField("taskTitlesEnabled", value)
telemetryClient.capture("ui_settings_task_titles_enabled_changed", {
enabled: value,
})
}
const handleReasoningBlockCollapsedChange = (value: boolean) => {
setCachedStateField("reasoningBlockCollapsed", value)
@ -54,6 +62,18 @@ export const UISettings = ({
<Section>
<div className="space-y-6">
<div className="flex flex-col gap-1">
<VSCodeCheckbox
checked={taskTitlesEnabled}
onChange={(e: any) => handleTaskTitlesEnabledChange(e.target.checked)}
data-testid="enable-task-titles-checkbox">
<span className="font-medium">{t("settings:ui.taskTitles.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.taskTitles.description")}
</div>
</div>
{/* Collapse Thinking Messages Setting */}
<SearchableSetting
settingId="ui-collapse-thinking"

View file

@ -4,6 +4,7 @@ import { UISettings } from "../UISettings"
describe("UISettings", () => {
const defaultProps = {
taskTitlesEnabled: false,
reasoningBlockCollapsed: false,
enterBehavior: "send" as const,
setCachedStateField: vi.fn(),
@ -15,6 +16,12 @@ describe("UISettings", () => {
expect(checkbox).toBeTruthy()
})
it("renders the task titles checkbox", () => {
const { getByTestId } = render(<UISettings {...defaultProps} />)
const checkbox = getByTestId("enable-task-titles-checkbox")
expect(checkbox).toBeTruthy()
})
it("displays the correct initial state", () => {
const { getByTestId } = render(<UISettings {...defaultProps} reasoningBlockCollapsed={true} />)
const checkbox = getByTestId("collapse-thinking-checkbox") as HTMLInputElement
@ -41,4 +48,18 @@ describe("UISettings", () => {
rerender(<UISettings {...defaultProps} reasoningBlockCollapsed={true} />)
expect(checkbox.checked).toBe(true)
})
it("calls setCachedStateField when task titles checkbox is toggled", async () => {
const setCachedStateField = vi.fn()
const { getByTestId } = render(
<UISettings {...defaultProps} setCachedStateField={setCachedStateField} taskTitlesEnabled={false} />,
)
const checkbox = getByTestId("enable-task-titles-checkbox")
fireEvent.click(checkbox)
await waitFor(() => {
expect(setCachedStateField).toHaveBeenCalledWith("taskTitlesEnabled", true)
})
})
})

View file

@ -148,6 +148,8 @@ export interface ExtensionStateContextType extends ExtensionState {
togglePinnedApiConfig: (configName: string) => void
terminalCompressProgressBar?: boolean
setTerminalCompressProgressBar: (value: boolean) => void
taskTitlesEnabled?: boolean
setTaskTitlesEnabled: (value: boolean) => void
setHistoryPreviewCollapsed: (value: boolean) => void
setReasoningBlockCollapsed: (value: boolean) => void
enterBehavior?: "send" | "newline"
@ -253,6 +255,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
historyPreviewCollapsed: false, // Initialize the new state (default to expanded)
reasoningBlockCollapsed: true, // Default to collapsed
enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline
taskTitlesEnabled: false,
cloudUserInfo: null,
cloudIsAuthenticated: false,
cloudOrganizations: [],
@ -454,6 +457,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const contextValue: ExtensionStateContextType = {
...state,
reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true,
taskTitlesEnabled: state.taskTitlesEnabled ?? false,
didHydrateState,
showWelcome,
theme,
@ -574,6 +578,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setState((prevState) => ({ ...prevState, reasoningBlockCollapsed: value })),
enterBehavior: state.enterBehavior ?? "send",
setEnterBehavior: (value) => setState((prevState) => ({ ...prevState, enterBehavior: value })),
setTaskTitlesEnabled: (value) => setState((prevState) => ({ ...prevState, taskTitlesEnabled: value })),
setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })),
setAutoCondenseContext: (value) => setState((prevState) => ({ ...prevState, autoCondenseContext: value })),
setAutoCondenseContextPercent: (value) =>

View file

@ -70,6 +70,10 @@
"description": "Manage your slash commands to quickly execute custom workflows and actions. <DocsLink>Learn more</DocsLink>"
},
"ui": {
"taskTitles": {
"label": "Enable editable task titles",
"description": "Show and edit custom titles for tasks in chat and history instead of only the original task text"
},
"collapseThinking": {
"label": "Collapse Thinking messages by default",
"description": "When enabled, thinking blocks will be collapsed by default until you interact with them"