feat: Add editable titles to tasks and task history

- Add optional title field to HistoryItem type schema
- Add updateTaskTitle message types to Extension and Webview messages
- Implement title update handler in webviewMessageHandler with trimming
- Add title editing UI to TaskHeader component with inline edit mode
- Update TaskItem component to display titles prominently in history
- Add translations for title editing UI elements
- Add tests for backend title update functionality

Implements #8366
This commit is contained in:
Roo Code 2025-09-29 17:03:54 +00:00
parent ded23b72b7
commit da79983d45
10 changed files with 553 additions and 7 deletions

View file

@ -11,6 +11,7 @@ export const historyItemSchema = z.object({
number: z.number(),
ts: z.number(),
task: z.string(),
title: z.string().optional(), // User-defined title for the task
tokensIn: z.number(),
tokensOut: z.number(),
cacheWrites: z.number().optional(),

View file

@ -37,6 +37,7 @@ const mockClineProvider = {
getCurrentTask: vi.fn(),
getTaskWithId: vi.fn(),
createTaskWithHistoryItem: vi.fn(),
updateTaskHistory: vi.fn(),
} as unknown as ClineProvider
import { t } from "../../../i18n"
@ -708,3 +709,188 @@ describe("webviewMessageHandler - mcpEnabled", () => {
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
})
describe("webviewMessageHandler - updateTaskTitle", () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock getGlobalState to return a task history
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue([
{
id: "task-1",
ts: 123456789,
task: "Original task text",
title: "Original title",
},
{
id: "task-2",
ts: 987654321,
task: "Another task",
// No title
},
])
})
it("should update task title when task exists", async () => {
// Mock updateTaskHistory to succeed
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "task-1",
title: "New updated title",
})
// Verify updateTaskHistory was called with the updated task
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
id: "task-1",
ts: 123456789,
task: "Original task text",
title: "New updated title",
})
// Verify state was posted to webview
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
})
it("should clear task title when empty string is provided", async () => {
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "task-1",
title: "",
})
// Verify updateTaskHistory was called with undefined title
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
id: "task-1",
ts: 123456789,
task: "Original task text",
title: undefined,
})
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
})
it("should add title to task that didn't have one", async () => {
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "task-2",
title: "Brand new title",
})
// Verify updateTaskHistory was called with the new title
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
id: "task-2",
ts: 987654321,
task: "Another task",
title: "Brand new title",
})
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
})
it("should not update when task is not found", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "non-existent-task",
title: "Some title",
})
// Verify updateTaskHistory was NOT called
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
// State should not be posted either
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
})
it("should not update when taskId is missing", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
// No taskId provided
title: "Some title",
})
// Verify updateTaskHistory was NOT called
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
// State should not be posted either
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
})
it("should handle empty task history gracefully", async () => {
// Mock empty task history
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined)
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "task-1",
title: "New title",
})
// Verify updateTaskHistory was NOT called
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
// State should not be posted either
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
})
it("should handle null task history gracefully", async () => {
// Mock null task history
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(null)
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "task-1",
title: "New title",
})
// Verify updateTaskHistory was NOT called
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
// State should not be posted either
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
})
it("should trim whitespace from title", async () => {
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "task-1",
title: " Trimmed Title ",
})
// Verify updateTaskHistory was called with trimmed title
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
id: "task-1",
ts: 123456789,
task: "Original task text",
title: "Trimmed Title",
})
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
})
it("should handle whitespace-only title as empty", async () => {
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
await webviewMessageHandler(mockClineProvider, {
type: "updateTaskTitle",
taskId: "task-1",
title: " ",
})
// Verify updateTaskHistory was called with undefined (cleared title)
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
id: "task-1",
ts: 123456789,
task: "Original task text",
title: undefined,
})
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
})
})

View file

@ -3110,5 +3110,34 @@ export const webviewMessageHandler = async (
})
break
}
case "updateTaskTitle": {
// Handle task title update
if (message.taskId && message.title !== undefined) {
try {
// Get the current task history
const history = getGlobalState("taskHistory") ?? []
const taskItem = history.find((item) => item.id === message.taskId)
if (taskItem) {
// Update the title field - trim whitespace and set to undefined if empty
const trimmedTitle = message.title?.trim()
taskItem.title = trimmedTitle || undefined // Set to undefined if empty string
// Save the updated task item
await provider.updateTaskHistory(taskItem)
// Post updated state back to webview
await provider.postStateToWebview()
} else {
provider.log(`Task not found for title update: ${message.taskId}`)
vscode.window.showErrorMessage(t("common:errors.task_not_found"))
}
} catch (error) {
provider.log(`Error updating task title: ${error instanceof Error ? error.message : String(error)}`)
vscode.window.showErrorMessage(t("common:errors.update_task_title"))
}
}
break
}
}
}

View file

@ -97,6 +97,8 @@
"error_deleting_message": "Error deleting message: {{error}}",
"error_editing_message": "Error editing message: {{error}}"
},
"task_not_found": "Task not found",
"update_task_title": "Failed to update task title",
"gemini": {
"generate_stream": "Gemini generate context stream error: {{error}}",
"generate_complete_prompt": "Gemini completion error: {{error}}",

View file

@ -126,8 +126,11 @@ export interface ExtensionMessage {
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "organizationSwitchResult"
| "taskTitleUpdated"
text?: string
payload?: any // Add a generic payload for now, can refine later
taskId?: string // For task title updates
title?: string // For task title updates
action?:
| "chatButtonClicked"
| "mcpButtonClicked"

View file

@ -61,6 +61,7 @@ export interface WebviewMessage {
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "updateTaskTitle"
| "importSettings"
| "exportSettings"
| "resetState"
@ -235,6 +236,8 @@ export interface WebviewMessage {
disabled?: boolean
context?: string
dataUri?: string
taskId?: string // For task title updates
title?: string // For task title updates
askResponse?: ClineAskResponse
apiConfiguration?: ProviderSettings
images?: string[]

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import DismissibleUpsell from "@src/components/common/DismissibleUpsell"
import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react"
import { FoldVertical, ChevronUp, ChevronDown, Edit2, Check, X } from "lucide-react"
import prettyBytes from "pretty-bytes"
import type { ClineMessage } from "@roo-code/types"
@ -16,6 +16,7 @@ import { cn } from "@src/lib/utils"
import { StandardTooltip } from "@src/components/ui"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { vscode } from "@src/utils/vscode"
import Thumbnails from "../common/Thumbnails"
@ -54,6 +55,9 @@ const TaskHeader = ({
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
const [isEditingTitle, setIsEditingTitle] = useState(false)
const [editedTitle, setEditedTitle] = useState(currentTaskItem?.title || "")
const titleInputRef = useRef<HTMLInputElement>(null)
const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({
autoOpenOnAuth: false,
})
@ -82,6 +86,36 @@ const TaskHeader = ({
return () => clearTimeout(timer)
}, [currentTaskItem, isTaskComplete])
// Update edited title when currentTaskItem changes
useEffect(() => {
setEditedTitle(currentTaskItem?.title || "")
}, [currentTaskItem?.title])
// Focus input when editing starts
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.select()
}
}, [isEditingTitle])
const handleSaveTitle = () => {
if (currentTaskItem) {
// Send message to update the task title
vscode.postMessage({
type: "updateTaskTitle",
taskId: currentTaskItem.id,
title: editedTitle.trim(),
})
}
setIsEditingTitle(false)
}
const handleCancelEdit = () => {
setEditedTitle(currentTaskItem?.title || "")
setIsEditingTitle(false)
}
const textContainerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
const contextWindow = model?.contextWindow || 1
@ -143,11 +177,65 @@ const TaskHeader = ({
<div className="flex justify-between items-center gap-0">
<div className="flex items-center select-none grow min-w-0">
<div className="whitespace-nowrap overflow-hidden text-ellipsis grow min-w-0">
{isTaskExpanded && <span className="font-bold">{t("chat:task.title")}</span>}
{isTaskExpanded && (
<div className="flex items-center gap-2">
{isEditingTitle ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={titleInputRef}
type="text"
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSaveTitle()
} else if (e.key === "Escape") {
handleCancelEdit()
}
}}
className="px-2 py-0.5 bg-vscode-input-background border border-vscode-input-border rounded text-vscode-input-foreground"
placeholder={t("chat:task.titlePlaceholder")}
/>
<StandardTooltip content={t("chat:task.saveTitle")}>
<button
onClick={handleSaveTitle}
className="p-1 hover:bg-vscode-button-hoverBackground rounded">
<Check size={14} />
</button>
</StandardTooltip>
<StandardTooltip content={t("chat:task.cancelEdit")}>
<button
onClick={handleCancelEdit}
className="p-1 hover:bg-vscode-button-hoverBackground rounded">
<X size={14} />
</button>
</StandardTooltip>
</div>
) : (
<>
<span className="font-bold">
{currentTaskItem?.title || t("chat:task.title")}
</span>
<StandardTooltip content={t("chat:task.editTitle")}>
<button
onClick={(e) => {
e.stopPropagation()
setIsEditingTitle(true)
}}
className="p-1 opacity-60 hover:opacity-100 hover:bg-vscode-button-hoverBackground rounded">
<Edit2 size={14} />
</button>
</StandardTooltip>
</>
)}
</div>
)}
{!isTaskExpanded && (
<div>
<span className="font-bold mr-1">{t("chat:task.title")}</span>
<Mention text={task.text} />
<div className="flex items-center gap-1">
<span className="font-bold mr-1">
{currentTaskItem?.title || t("chat:task.title")}
</span>
{!currentTaskItem?.title && <Mention text={task.text} />}
</div>
)}
</div>

View file

@ -35,7 +35,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({
// Create a variable to hold the mock state
let mockExtensionState: {
apiConfiguration: ProviderSettings
currentTaskItem: { id: string } | null
currentTaskItem: { id: string; title?: string } | null
clineMessages: any[]
} = {
apiConfiguration: {
@ -357,4 +357,223 @@ describe("TaskHeader", () => {
expect(screen.getByTestId("dismissible-upsell")).toBeInTheDocument()
})
})
describe("Title editing functionality", () => {
beforeEach(() => {
// Reset the mock state before each test
mockExtensionState = {
apiConfiguration: {
apiProvider: "anthropic",
apiKey: "test-api-key",
apiModelId: "claude-3-opus-20240229",
} as ProviderSettings,
currentTaskItem: { id: "test-task-id", title: "Existing Title" },
clineMessages: [],
}
// Clear mock calls
vi.clearAllMocks()
})
it("should display the title if it exists", () => {
renderTaskHeader()
expect(screen.getByText("Existing Title")).toBeInTheDocument()
})
it("should display task text when no title exists", () => {
mockExtensionState.currentTaskItem = { id: "test-task-id" }
renderTaskHeader()
expect(screen.getByText("Test task")).toBeInTheDocument()
})
it("should show edit button when hovering over title", () => {
renderTaskHeader()
const titleElement = screen.getByText("Existing Title")
// Initially, edit button should not be visible
expect(screen.queryByLabelText("chat:editTitle")).not.toBeInTheDocument()
// Hover over the title
fireEvent.mouseEnter(titleElement.parentElement!)
// Edit button should now be visible
expect(screen.getByLabelText("chat:editTitle")).toBeInTheDocument()
})
it("should enter edit mode when edit button is clicked", () => {
renderTaskHeader()
const titleElement = screen.getByText("Existing Title")
// Hover and click edit button
fireEvent.mouseEnter(titleElement.parentElement!)
const editButton = screen.getByLabelText("chat:editTitle")
fireEvent.click(editButton)
// Should show input field with current title
const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement
expect(input).toBeInTheDocument()
expect(input.tagName).toBe("INPUT")
// Should show save and cancel buttons
expect(screen.getByLabelText("chat:saveTitle")).toBeInTheDocument()
expect(screen.getByLabelText("chat:cancelEdit")).toBeInTheDocument()
})
it("should save title when save button is clicked", () => {
const { vscode } = require("@/utils/vscode")
renderTaskHeader()
// Enter edit mode
const titleElement = screen.getByText("Existing Title")
fireEvent.mouseEnter(titleElement.parentElement!)
fireEvent.click(screen.getByLabelText("chat:editTitle"))
// Change the title
const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement
fireEvent.change(input, { target: { value: "New Title" } })
// Click save
fireEvent.click(screen.getByLabelText("chat:saveTitle"))
// Should post message to update title
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateTaskTitle",
taskId: "test-task-id",
title: "New Title",
})
})
it("should cancel editing when cancel button is clicked", () => {
renderTaskHeader()
// Enter edit mode
const titleElement = screen.getByText("Existing Title")
fireEvent.mouseEnter(titleElement.parentElement!)
fireEvent.click(screen.getByLabelText("chat:editTitle"))
// Change the title
const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement
fireEvent.change(input, { target: { value: "Changed Title" } })
// Click cancel
fireEvent.click(screen.getByLabelText("chat:cancelEdit"))
// Should exit edit mode and show original title
expect(screen.getByText("Existing Title")).toBeInTheDocument()
expect(screen.queryByDisplayValue("Changed Title")).not.toBeInTheDocument()
})
it("should save title when Enter key is pressed", () => {
const { vscode } = require("@/utils/vscode")
renderTaskHeader()
// Enter edit mode
const titleElement = screen.getByText("Existing Title")
fireEvent.mouseEnter(titleElement.parentElement!)
fireEvent.click(screen.getByLabelText("chat:editTitle"))
// Change the title and press Enter
const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement
fireEvent.change(input, { target: { value: "Enter Key Title" } })
fireEvent.keyDown(input, { key: "Enter" })
// Should post message to update title
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateTaskTitle",
taskId: "test-task-id",
title: "Enter Key Title",
})
})
it("should cancel editing when Escape key is pressed", () => {
renderTaskHeader()
// Enter edit mode
const titleElement = screen.getByText("Existing Title")
fireEvent.mouseEnter(titleElement.parentElement!)
fireEvent.click(screen.getByLabelText("chat:editTitle"))
// Change the title and press Escape
const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement
fireEvent.change(input, { target: { value: "Escape Test" } })
fireEvent.keyDown(input, { key: "Escape" })
// Should exit edit mode and show original title
expect(screen.getByText("Existing Title")).toBeInTheDocument()
expect(screen.queryByDisplayValue("Escape Test")).not.toBeInTheDocument()
})
it("should clear title when empty string is saved", () => {
const { vscode } = require("@/utils/vscode")
renderTaskHeader()
// Enter edit mode
const titleElement = screen.getByText("Existing Title")
fireEvent.mouseEnter(titleElement.parentElement!)
fireEvent.click(screen.getByLabelText("chat:editTitle"))
// Clear the title
const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement
fireEvent.change(input, { target: { value: "" } })
// Click save
fireEvent.click(screen.getByLabelText("chat:saveTitle"))
// Should post message with empty title
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateTaskTitle",
taskId: "test-task-id",
title: "",
})
})
it("should show placeholder when no title exists and in edit mode", () => {
mockExtensionState.currentTaskItem = { id: "test-task-id" }
renderTaskHeader()
// Enter edit mode
const taskElement = screen.getByText("Test task")
fireEvent.mouseEnter(taskElement.parentElement!)
fireEvent.click(screen.getByLabelText("chat:editTitle"))
// Should show input with placeholder
const input = screen.getByPlaceholderText("chat:titlePlaceholder") as HTMLInputElement
expect(input).toBeInTheDocument()
expect(input.value).toBe("")
})
it("should not show edit button when task is null", () => {
mockExtensionState.currentTaskItem = null
renderTaskHeader()
const taskElement = screen.getByText("Test task")
fireEvent.mouseEnter(taskElement.parentElement!)
// Edit button should not be present
expect(screen.queryByLabelText("chat:editTitle")).not.toBeInTheDocument()
})
it("should trim whitespace from saved title", () => {
const { vscode } = require("@/utils/vscode")
renderTaskHeader()
// Enter edit mode
const titleElement = screen.getByText("Existing Title")
fireEvent.mouseEnter(titleElement.parentElement!)
fireEvent.click(screen.getByLabelText("chat:editTitle"))
// Add title with whitespace
const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement
fireEvent.change(input, { target: { value: " Trimmed Title " } })
// Click save
fireEvent.click(screen.getByLabelText("chat:saveTitle"))
// Should post message with trimmed title
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateTaskTitle",
taskId: "test-task-id",
title: "Trimmed Title",
})
})
})
})

View file

@ -68,11 +68,22 @@ const TaskItem = ({
)}
<div className="flex-1 min-w-0">
{/* Display title if it exists */}
{item.title && (
<div
className={cn("font-semibold text-vscode-foreground mb-1", {
"text-base": !isCompact,
})}>
{item.title}
</div>
)}
<div
className={cn(
"overflow-hidden whitespace-pre-wrap text-vscode-foreground text-ellipsis line-clamp-2",
"overflow-hidden whitespace-pre-wrap text-ellipsis line-clamp-2",
{
"text-base": !isCompact,
"text-vscode-descriptionForeground": item.title, // Make task text secondary if title exists
"text-vscode-foreground": !item.title, // Primary color if no title
},
!isCompact && isSelectionMode ? "mb-1" : "",
)}

View file

@ -2,6 +2,10 @@
"greeting": "Welcome to Roo Code!",
"task": {
"title": "Task",
"titlePlaceholder": "Enter a title for this task...",
"editTitle": "Edit task title",
"saveTitle": "Save title",
"cancelEdit": "Cancel editing",
"expand": "Expand task",
"collapse": "Collapse task",
"seeMore": "See more",