feat: add editable titles to task history

This commit is contained in:
Jakob Malmo 2025-09-29 18:10:34 +02:00 committed by Roo Code
parent 0f08867656
commit 1524d66ee4
12 changed files with 448 additions and 22 deletions

View file

@ -10,6 +10,7 @@ export const historyItemSchema = z.object({
parentTaskId: z.string().optional(),
number: z.number(),
ts: z.number(),
title: z.string().optional(),
task: z.string(),
tokensIn: z.number(),
tokensOut: z.number(),

View file

@ -389,6 +389,7 @@ export interface WebviewMessage {
| "importSettings"
| "exportSettings"
| "resetState"
| "setTaskTitle"
| "flushRouterModels"
| "requestRouterModels"
| "requestOpenAiModels"

View file

@ -2479,13 +2479,19 @@ export class ClineProvider
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
// Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten.
// This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened,
// terminated, or when routine message persistence occurs.
history[existingItemIndex] = {
...history[existingItemIndex],
const existingItem = history[existingItemIndex]
const hasTitleProp = Object.prototype.hasOwnProperty.call(item, "title")
// Preserve existing metadata unless explicitly overwritten.
// Title is only cleared when explicitly provided (including undefined).
const mergedItem: HistoryItem = {
...existingItem,
...item,
}
if (!hasTitleProp) {
mergedItem.title = existingItem.title
}
history[existingItemIndex] = mergedItem
} else {
history.push(item)
}

View file

@ -1230,4 +1230,63 @@ describe("ClineProvider - Sticky Mode", () => {
})
})
})
describe("updateTaskHistory", () => {
beforeEach(async () => {
await provider.resolveWebviewView(mockWebviewView)
})
it("preserves existing title when update omits the title property", async () => {
const baseItem: HistoryItem = {
id: "task-with-title",
number: 1,
ts: Date.now(),
task: "Original task",
tokensIn: 10,
tokensOut: 20,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
title: "Custom title",
}
await provider.updateTaskHistory(baseItem)
const itemWithoutTitle = { ...baseItem }
delete (itemWithoutTitle as any).title
itemWithoutTitle.tokensIn = 42
await provider.updateTaskHistory(itemWithoutTitle as HistoryItem)
const history = mockContext.globalState.get("taskHistory") as HistoryItem[]
expect(history[0]?.title).toBe("Custom title")
})
it("allows clearing a title when explicitly set to undefined", async () => {
const baseItem: HistoryItem = {
id: "task-clear-title",
number: 1,
ts: Date.now(),
task: "Another task",
tokensIn: 5,
tokensOut: 15,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
title: "Temporary title",
}
await provider.updateTaskHistory(baseItem)
const clearedItem: HistoryItem = {
...baseItem,
title: undefined,
}
await provider.updateTaskHistory(clearedItem)
const history = mockContext.globalState.get("taskHistory") as HistoryItem[]
expect(history[0]?.title).toBeUndefined()
})
})
})

View file

@ -10,6 +10,7 @@ import {
type Language,
type GlobalState,
type ClineMessage,
type HistoryItem,
type TelemetrySetting,
type UserSettingsConfig,
type ModelRecord,
@ -728,6 +729,57 @@ export const webviewMessageHandler = async (
vscode.window.showErrorMessage(t("common:errors.share_task_failed"))
}
break
case "setTaskTitle": {
const ids = Array.isArray(message.ids)
? Array.from(
new Set(
message.ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0),
),
)
: []
if (ids.length === 0) {
break
}
const rawTitle = message.text ?? ""
const trimmedTitle = rawTitle.trim()
const normalizedTitle = trimmedTitle.length > 0 ? trimmedTitle : undefined
const { taskHistory } = await provider.getState()
if (!Array.isArray(taskHistory) || taskHistory.length === 0) {
break
}
let hasUpdates = false
const historyById = new Map(taskHistory.map((item) => [item.id, item] as const))
for (const id of ids) {
const existingItem = historyById.get(id)
if (!existingItem) {
console.warn(`[setTaskTitle] Unable to locate task history item with id ${id}`)
continue
}
const normalizedExistingTitle =
existingItem.title && existingItem.title.trim().length > 0 ? existingItem.title.trim() : undefined
if (normalizedExistingTitle === normalizedTitle) {
continue
}
const updatedItem: HistoryItem = {
...existingItem,
title: normalizedTitle,
}
await provider.updateTaskHistory(updatedItem)
hasUpdates = true
}
if (hasUpdates) {
await provider.postStateToWebview()
}
break
}
case "showTaskWithId":
provider.showTaskWithId(message.text!)
break

View file

@ -1,4 +1,5 @@
import { memo, useEffect, useRef, useState, useMemo } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { KeyboardEvent as ReactKeyboardEvent } from "react"
import { useTranslation } from "react-i18next"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
@ -12,6 +13,7 @@ import {
HardDriveUpload,
FoldVertical,
Globe,
Pencil,
} from "lucide-react"
import prettyBytes from "pretty-bytes"
@ -26,6 +28,7 @@ import { StandardTooltip, Button } from "@src/components/ui"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { vscode } from "@src/utils/vscode"
import { DecoratedVSCodeTextField } from "@src/components/common/DecoratedVSCodeTextField"
import Thumbnails from "../common/Thumbnails"
@ -67,7 +70,13 @@ const TaskHeader = ({
todos,
}: TaskHeaderProps) => {
const { t } = useTranslation()
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
const {
apiConfiguration,
currentTaskItem,
clineMessages,
isBrowserSessionActive,
taskTitlesEnabled = false,
} = useExtensionState()
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
@ -99,6 +108,103 @@ const TaskHeader = ({
return () => clearTimeout(timer)
}, [currentTaskItem, isTaskComplete])
const [isEditingTitle, setIsEditingTitle] = useState(false)
const [titleInput, setTitleInput] = useState(currentTaskItem?.title ?? "")
const titleInputRef = useRef<HTMLInputElement | null>(null)
const skipBlurSubmitRef = useRef(false)
const currentTitle = currentTaskItem?.title?.trim() ?? ""
useEffect(() => {
if (!isEditingTitle) {
setTitleInput(currentTaskItem?.title ?? "")
}
}, [currentTaskItem?.title, isEditingTitle])
useEffect(() => {
setIsEditingTitle(false)
}, [currentTaskItem?.id])
useEffect(() => {
if (!taskTitlesEnabled) {
setIsEditingTitle(false)
return
}
if (isEditingTitle) {
skipBlurSubmitRef.current = false
requestAnimationFrame(() => {
titleInputRef.current?.focus()
titleInputRef.current?.select()
})
}
}, [isEditingTitle, taskTitlesEnabled])
const submitTitle = useCallback(() => {
if (!taskTitlesEnabled) {
return
}
if (!currentTaskItem) {
setIsEditingTitle(false)
return
}
const trimmed = titleInput.trim()
const existingTrimmed = currentTaskItem.title?.trim() ?? ""
setIsEditingTitle(false)
if (trimmed === existingTrimmed) {
setTitleInput(currentTaskItem.title ?? "")
return
}
vscode.postMessage({
type: "setTaskTitle",
text: trimmed,
ids: [currentTaskItem.id],
})
setTitleInput(trimmed)
}, [currentTaskItem, taskTitlesEnabled, titleInput])
useEffect(() => {
if (!isEditingTitle) {
skipBlurSubmitRef.current = false
}
}, [isEditingTitle])
const handleTitleBlur = useCallback(() => {
if (!taskTitlesEnabled) {
return
}
if (skipBlurSubmitRef.current) {
skipBlurSubmitRef.current = false
return
}
submitTitle()
}, [submitTitle, taskTitlesEnabled])
const handleTitleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (!taskTitlesEnabled) {
return
}
if (event.key === "Enter") {
event.preventDefault()
skipBlurSubmitRef.current = true
submitTitle()
} else if (event.key === "Escape") {
event.preventDefault()
skipBlurSubmitRef.current = true
setIsEditingTitle(false)
setTitleInput(currentTaskItem?.title ?? "")
}
},
[currentTaskItem?.title, submitTitle, taskTitlesEnabled],
)
const textContainerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
const contextWindow = model?.contextWindow || 1
@ -124,7 +230,96 @@ const TaskHeader = ({
/>
)
const renderTitleEditor = () => (
<div onClick={(event) => event.stopPropagation()} className="w-full" data-testid="task-title-editor">
<DecoratedVSCodeTextField
ref={titleInputRef}
value={titleInput}
onInput={(event: any) => setTitleInput(event.target.value)}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
placeholder={t("chat:task.titlePlaceholder")}
data-testid="task-title-input"
/>
</div>
)
const renderTitleAction = () => {
if (!taskTitlesEnabled || !currentTaskItem || isEditingTitle) {
return null
}
const tooltipKey = currentTitle.length > 0 ? "chat:task.editTitle" : "chat:task.addTitle"
return (
<StandardTooltip content={t(tooltipKey)}>
<button
type="button"
className="shrink-0 min-h-[20px] min-w-[20px] p-[2px] cursor-pointer opacity-85 hover:opacity-100 bg-transparent border-none rounded-md text-inherit"
onClick={(event) => {
event.stopPropagation()
skipBlurSubmitRef.current = false
setTitleInput(currentTitle)
setIsEditingTitle(true)
}}
aria-label={t(tooltipKey)}
data-testid="task-title-edit-button">
<Pencil size={16} />
</button>
</StandardTooltip>
)
}
const renderCollapsedTitleContent = () => {
if (!taskTitlesEnabled || !currentTaskItem) {
return (
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
<Mention text={task.text} />
</span>
)
}
if (isEditingTitle) {
return renderTitleEditor()
}
if (currentTitle.length > 0) {
return (
<span className="truncate text-base" data-testid="task-title-text">
{currentTitle}
</span>
)
}
return (
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
<Mention text={task.text} />
</span>
)
}
const renderExpandedTitleContent = () => {
if (!taskTitlesEnabled || !currentTaskItem) {
return null
}
if (isEditingTitle) {
return renderTitleEditor()
}
if (currentTitle.length > 0) {
return (
<span className="text-base" data-testid="task-title-text">
{currentTitle}
</span>
)
}
return null
}
const hasTodos = todos && Array.isArray(todos) && todos.length > 0
const expandedTitleContent = renderExpandedTitleContent()
return (
<div className="group pt-2 pb-0 px-3">
@ -175,18 +370,25 @@ const TaskHeader = ({
<div className="flex justify-between items-center gap-0">
<div className="flex items-center select-none grow min-w-0">
<div className="grow min-w-0">
{isTaskExpanded && <span className="font-bold">{t("chat:task.title")}</span>}
{!isTaskExpanded && (
<div className="flex items-center gap-2">
{isTaskExpanded ? (
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-bold">{t("chat:task.title")}</span>
{renderTitleAction()}
</div>
{expandedTitleContent ? <div className="min-w-0">{expandedTitleContent}</div> : null}
</div>
) : (
<div className="flex items-center gap-2 min-w-0">
<SquarePen className="size-3 shrink-0" />
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
<Mention text={task.text} />
</span>
<div className="min-w-0 flex-1">{renderCollapsedTitleContent()}</div>
{renderTitleAction()}
</div>
)}
</div>
<div className="flex items-center shrink-0 ml-2" onClick={(e) => e.stopPropagation()}>
<StandardTooltip content={isTaskExpanded ? t("chat:task.collapse") : t("chat:task.expand")}>
<StandardTooltip
content={isTaskExpanded ? t("chat:task.collapse") : t("chat:task.expand")}>
<button
onClick={() => setIsTaskExpanded(!isTaskExpanded)}
className="shrink-0 min-h-[20px] min-w-[20px] p-[2px] cursor-pointer opacity-85 hover:opacity-100 bg-transparent border-none rounded-md">
@ -352,7 +554,7 @@ const TaskHeader = ({
{t("chat:task.contextWindow")}
</th>
<td className="font-light align-top">
<div className={`max-w-md -mt-1.5 flex flex-nowrap gap-1`}>
<div className="max-w-md -mt-1.5 flex flex-nowrap gap-1">
<ContextWindowProgress
contextWindow={contextWindow}
contextTokens={contextTokens || 0}

View file

@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ProviderSettings } from "@roo-code/types"
import TaskHeader, { TaskHeaderProps } from "../TaskHeader"
import { vscode } from "@/utils/vscode"
// Mock i18n
vi.mock("react-i18next", () => ({
@ -27,9 +28,20 @@ vi.mock("@/utils/vscode", () => ({
},
}))
// Mock the VSCodeBadge component
// Mock the VSCodeBadge/TextField components
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeBadge: ({ children }: { children: React.ReactNode }) => <div data-testid="vscode-badge">{children}</div>,
VSCodeTextField: React.forwardRef<HTMLInputElement, any>(
({ onInput, "data-testid": dataTestId, value = "", ...rest }: any, ref) => (
<input
ref={ref}
data-testid={dataTestId}
value={value}
onChange={(event) => onInput?.({ target: event.target })}
{...rest}
/>
),
),
}))
// Create a variable to hold the mock state
@ -89,6 +101,18 @@ vi.mock("@roo/array", () => ({
}))
describe("TaskHeader", () => {
beforeEach(() => {
vi.clearAllMocks()
mockExtensionState = {
apiConfiguration: {
apiProvider: "anthropic",
apiKey: "test-api-key",
apiModelId: "claude-3-opus-20240229",
} as ProviderSettings,
currentTaskItem: { id: "test-task-id" },
clineMessages: [],
}
})
const defaultProps: TaskHeaderProps = {
task: { type: "say", ts: Date.now(), text: "Test task", images: [] },
tokensIn: 100,
@ -180,6 +204,23 @@ describe("TaskHeader", () => {
expect(handleCondenseContext).not.toHaveBeenCalled()
})
it("posts setTaskTitle message when editing title", () => {
renderTaskHeader()
const editButton = screen.getByTestId("task-title-edit-button")
fireEvent.click(editButton)
const input = screen.getByTestId("task-title-input")
fireEvent.change(input, { target: { value: "New task title" } })
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "setTaskTitle",
text: "New task title",
ids: ["test-task-id"],
})
})
describe("DismissibleUpsell behavior", () => {
beforeEach(() => {
vi.useFakeTimers()

View file

@ -9,6 +9,7 @@ import TaskItemFooter from "./TaskItemFooter"
interface DisplayHistoryItem extends HistoryItem {
highlight?: string
titleHighlight?: string
}
interface TaskItemProps {
@ -69,6 +70,26 @@ 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",

View file

@ -80,6 +80,21 @@ describe("TaskItem", () => {
expect(screen.getByTestId("export")).toBeInTheDocument()
})
it("renders title above task content when provided", () => {
render(
<TaskItem
item={{ ...mockTask, title: "Important task" }}
variant="full"
isSelected={false}
onToggleSelection={vi.fn()}
isSelectionMode={false}
/>,
)
expect(screen.getByTestId("task-item-title")).toHaveTextContent("Important task")
expect(screen.getByTestId("task-content")).toHaveTextContent("Test task")
})
it("displays time ago information", () => {
render(
<TaskItem

View file

@ -20,6 +20,7 @@ const mockTaskHistory: HistoryItem[] = [
{
id: "task-1",
number: 1,
title: "Build component",
task: "Create a React component",
ts: new Date("2022-02-16T12:00:00").getTime(),
tokensIn: 100,
@ -30,6 +31,7 @@ const mockTaskHistory: HistoryItem[] = [
{
id: "task-2",
number: 2,
title: "Write tests",
task: "Write unit tests",
ts: new Date("2022-02-17T12:00:00").getTime(),
tokensIn: 200,
@ -154,6 +156,19 @@ describe("useTaskSearch", () => {
expect((result.current.tasks[0] as any).highlight).toBe("<mark>Create a React component</mark>")
})
it("matches search queries against task titles", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSearchQuery("build")
})
expect(result.current.tasks).toHaveLength(1)
expect(result.current.tasks[0].id).toBe("task-1")
expect((result.current.tasks[0] as any).titleHighlight).toBe("<mark>Build component</mark>")
})
it("automatically switches to mostRelevant when searching", () => {
const { result } = renderHook(() => useTaskSearch())

View file

@ -33,7 +33,7 @@ export const useTaskSearch = () => {
const fzf = useMemo(() => {
return new Fzf(presentableTasks, {
selector: (item) => item.task,
selector: (item) => (item.title ? `${item.title} ${item.task}` : item.task),
})
}, [presentableTasks])
@ -44,14 +44,24 @@ export const useTaskSearch = () => {
const searchResults = fzf.find(searchQuery)
results = searchResults.map((result) => {
const positions = Array.from(result.positions)
const taskEndIndex = result.item.task.length
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 titleHighlight =
titlePositions.length > 0 && result.item.title
? highlightFzfMatch(result.item.title, titlePositions)
: undefined
const taskHighlight =
taskPositions.length > 0 ? highlightFzfMatch(result.item.task, taskPositions) : undefined
return {
...result.item,
highlight: highlightFzfMatch(
result.item.task,
positions.filter((p) => p < taskEndIndex),
),
titleHighlight,
highlight: taskHighlight,
workspace: result.item.workspace,
}
})

View file

@ -11,6 +11,9 @@
"apiCost": "API Cost",
"size": "Size",
"condenseContext": "Intelligently condense context",
"addTitle": "Add title",
"editTitle": "Edit title",
"titlePlaceholder": "Add a title",
"contextWindow": "Context Length",
"closeAndStart": "Close task and start a new one",
"export": "Export task history",