From f14b9683dd4838e29fa8de3d5011d00c08ece4ce Mon Sep 17 00:00:00 2001 From: Jakob Malmo Date: Mon, 29 Sep 2025 19:54:48 +0200 Subject: [PATCH] feat: add setting to toggle task titles --- packages/types/src/global-settings.ts | 1 + packages/types/src/vscode-extension-host.ts | 2 + src/core/webview/ClineProvider.ts | 3 ++ src/core/webview/webviewMessageHandler.ts | 1 - webview-ui/src/components/chat/TaskHeader.tsx | 4 +- .../chat/__tests__/TaskHeader.spec.tsx | 12 ++++++ .../src/components/history/TaskItem.tsx | 38 ++++++++----------- .../history/__tests__/TaskItem.spec.tsx | 31 +++++++++++++-- .../history/__tests__/useTaskSearch.spec.tsx | 18 +++++++++ .../src/components/history/useTaskSearch.ts | 21 +++++----- .../src/components/settings/SettingsView.tsx | 3 ++ .../src/components/settings/UISettings.tsx | 20 ++++++++++ .../settings/__tests__/UISettings.spec.tsx | 21 ++++++++++ .../src/context/ExtensionStateContext.tsx | 5 +++ webview-ui/src/i18n/locales/en/settings.json | 4 ++ 15 files changed, 147 insertions(+), 37 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 9a17834ced..a56a125d08 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a06ccc45d1..44cdd60b0a 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -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 diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33dad9226f..e80c0afbde 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4a049079e9..eb84f43bec 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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") ?? {} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index f5da8e947d..fe8809828f 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -376,7 +376,9 @@ const TaskHeader = ({ {t("chat:task.title")} {renderTitleAction()} - {expandedTitleContent ?
{expandedTitleContent}
: null} + {expandedTitleContent ? ( +
{expandedTitleContent}
+ ) : null} ) : (
diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 7ee574604d..43f4b30dfb 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -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, } }) diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx index 67e512e7e5..832650a68d 100644 --- a/webview-ui/src/components/history/TaskItem.tsx +++ b/webview-ui/src/components/history/TaskItem.tsx @@ -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 (
- {(item.title || item.titleHighlight) && - (item.titleHighlight ? ( -
- ) : ( -
- {item.title} -
- ))}
- {item.highlight ? undefined : item.task} + data-testid="task-content"> + {displayHighlight ? ( + + ) : ( + {displayText} + )}
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( { />, ) - 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( + , + ) + + 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", () => { diff --git a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx index 16b4b7d414..f2c9161470 100644 --- a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx +++ b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx @@ -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("Build component") }) + 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()) diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 3c95f005e0..f8894617e3 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -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("newest") const [lastNonRelevantSort, setLastNonRelevantSort] = useState("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, diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index d86007e80f..6ae051fdb5 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -214,6 +214,7 @@ const SettingsView = forwardRef(({ onDone, t includeCurrentTime, includeCurrentCost, maxGitStatusFiles, + taskTitlesEnabled, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -423,6 +424,7 @@ const SettingsView = forwardRef(({ 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(({ onDone, t {/* UI Section */} {renderTab === "ui" && ( { + taskTitlesEnabled: boolean reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" setCachedStateField: SetCachedStateField } 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 = ({
+
+ handleTaskTitlesEnabledChange(e.target.checked)} + data-testid="enable-task-titles-checkbox"> + {t("settings:ui.taskTitles.label")} + +
+ {t("settings:ui.taskTitles.description")} +
+
+ {/* Collapse Thinking Messages Setting */} { 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() + const checkbox = getByTestId("enable-task-titles-checkbox") + expect(checkbox).toBeTruthy() + }) + it("displays the correct initial state", () => { const { getByTestId } = render() const checkbox = getByTestId("collapse-thinking-checkbox") as HTMLInputElement @@ -41,4 +48,18 @@ describe("UISettings", () => { rerender() expect(checkbox.checked).toBe(true) }) + + it("calls setCachedStateField when task titles checkbox is toggled", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + + const checkbox = getByTestId("enable-task-titles-checkbox") + fireEvent.click(checkbox) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("taskTitlesEnabled", true) + }) + }) }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d2ff79a8e0..ed4bb8eb77 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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) => diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index fc64ad1851..56456f9b93 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -70,6 +70,10 @@ "description": "Manage your slash commands to quickly execute custom workflows and actions. Learn more" }, "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"