Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const historyItemSchema = z.object({
parentTaskId: z.string().optional(),
number: z.number(),
ts: z.number(),
title: z.string().max(255).optional(),
task: z.string(),
tokensIn: z.number(),
tokensOut: z.number(),
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export type ExtensionState = Pick<
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "taskTitlesEnabled"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down Expand Up @@ -302,6 +303,7 @@ export type ExtensionState = Pick<
renderContext: "sidebar" | "editor"
settingsImportedAt?: number
historyPreviewCollapsed?: boolean
taskTitlesEnabled?: boolean

cloudUserInfo: CloudUserInfo | null
cloudIsAuthenticated: boolean
Expand Down Expand Up @@ -389,6 +391,7 @@ export interface WebviewMessage {
| "importSettings"
| "exportSettings"
| "resetState"
| "setTaskTitle"
| "flushRouterModels"
| "requestRouterModels"
| "requestOpenAiModels"
Expand Down
19 changes: 14 additions & 5 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,7 @@ export class ClineProvider
historyPreviewCollapsed,
reasoningBlockCollapsed,
enterBehavior,
taskTitlesEnabled,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2479,13 +2482,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)
}
Expand Down
59 changes: 59 additions & 0 deletions src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
102 changes: 102 additions & 0 deletions src/core/webview/taskTitleHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { HistoryItem } from "@roo-code/types"

import type { ClineProvider } from "./ClineProvider"
import type { WebviewMessage } from "../../shared/WebviewMessage"

const MAX_TITLE_LENGTH = 255

/**
* Sanitizes and normalizes a title string:
* - Removes control characters
* - Normalizes whitespace
* - Trims leading/trailing whitespace
* - Truncates if too long
* - Returns undefined for empty strings
*/
function normalizeTitle(title: string | undefined | null, ids?: string[]): string | undefined {
const rawTitle = title ?? ""

// Sanitize: remove control characters and normalize whitespace
const sanitized = rawTitle
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F-\x9F]/g, "") // Remove control characters
.replace(/\s+/g, " ") // Normalize whitespace
.trim()

// Clear empty titles
if (sanitized.length === 0) {
return undefined
}

// Truncate if too long
if (sanitized.length > MAX_TITLE_LENGTH) {
const truncated = sanitized.slice(0, MAX_TITLE_LENGTH).trim()
console.warn(
`[setTaskTitle] Title truncated from ${sanitized.length} to ${MAX_TITLE_LENGTH} chars for task(s): ${ids?.join(", ") ?? "unknown"}`,
)
return truncated
}

return sanitized
}

/**
* Handles the setTaskTitle webview message.
* Updates task titles for one or more history items, with deduplication and no-op detection.
*/
export async function handleSetTaskTitle(provider: ClineProvider, message: WebviewMessage): Promise<void> {
// 1. Validate and deduplicate incoming task IDs
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) {
return
}

// 2. Normalize the incoming title (with sanitization and truncation)
const normalizedTitle = normalizeTitle(message.text, ids)

// 3. Get task history from state
const { taskHistory } = await provider.getState()
if (!Array.isArray(taskHistory) || taskHistory.length === 0) {
return
}

// 4. Create a map for O(1) lookups
const historyById = new Map(taskHistory.map((item) => [item.id, item] as const))

// 5. Process each ID, skipping no-ops
let hasUpdates = false

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
}

// Normalize existing title for comparison
const normalizedExistingTitle =
existingItem.title && existingItem.title.trim().length > 0 ? existingItem.title.trim() : undefined

// Skip if title is unchanged
if (normalizedExistingTitle === normalizedTitle) {
continue
}

// Update the history item
const updatedItem: HistoryItem = {
...existingItem,
title: normalizedTitle,
}

await provider.updateTaskHistory(updatedItem)
hasUpdates = true
}

// 6. Sync webview state if there were changes
if (hasUpdates) {
await provider.postStateToWebview()
}
}
5 changes: 4 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ClineProvider } from "./ClineProvider"
import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager"
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
import { generateErrorDiagnostics } from "./diagnosticsHandler"
import { handleSetTaskTitle } from "./taskTitleHandler"
import { changeLanguage, t } from "../../i18n"
import { Package } from "../../shared/package"
import { type RouterName, toRouterName } from "../../shared/api"
Expand Down Expand Up @@ -728,6 +729,9 @@ export const webviewMessageHandler = async (
vscode.window.showErrorMessage(t("common:errors.share_task_failed"))
}
break
case "setTaskTitle":
await handleSetTaskTitle(provider, message)
break
case "showTaskWithId":
provider.showTaskWithId(message.text!)
break
Expand Down Expand Up @@ -1616,7 +1620,6 @@ export const webviewMessageHandler = async (
await updateGlobalState("hasOpenedModeSelector", message.bool ?? true)
await provider.postStateToWebview()
break

case "toggleApiConfigPin":
if (message.text) {
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}
Expand Down
Loading
Loading