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().optional(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Defense-in-Depth for Long Titles

Screenshot 2026-01-13 111028

I noticed that extremely long titles can visually overlap the edit button in the history sidebar (see screenshot). I recommend a two-pronged approach to handle this:

1. Data Integrity
We can enforce a hard limit here to prevent edge cases or massive strings.

Suggested change
title: z.string().optional(),
title: z.string().max(255).optional(),

2. CSS truncation
Add the block truncate classes to both title spans. This ensures that the titles properly truncate with an ellipsis (...) when they get too long. (see suggestions for TaskHeader.tsx)

task: z.string(),
tokensIn: z.number(),
tokensOut: z.number(),
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 @@ -1960,6 +1960,7 @@ export class ClineProvider
historyPreviewCollapsed,
reasoningBlockCollapsed,
enterBehavior,
taskTitlesEnabled,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down Expand Up @@ -2050,6 +2051,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 @@ -2358,6 +2360,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 @@ -2428,13 +2431,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()
})
})
})
53 changes: 52 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type Language,
type GlobalState,
type ClineMessage,
type HistoryItem,
type TelemetrySetting,
type UserSettingsConfig,
TelemetryEventName,
Expand Down Expand Up @@ -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
Comment on lines +745 to +746
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While .trim() handles the basics, we should probably be a bit more defensive here since this title might come from copy-pasted text.

I’ve drafted a stricter sanitizer that adds three layers of safety:

  1. Strip Control Characters: Removes invisible chars (like \x00) that can cause issues in JSON serialization or logs.
  2. Normalize Whitespace: Collapses multiple spaces and newlines into a single space. This keeps the history sidebar clean even if the user pastes a paragraph with line breaks.
  3. Graceful Truncation: Enforces the 255-char limit with a warning log, so we never accidentally store a massive string.

This ensures that normalizedTitle is always display-ready before it hits our state.

Suggested change
const trimmedTitle = rawTitle.trim()
const normalizedTitle = trimmedTitle.length > 0 ? trimmedTitle : undefined
const MAX_TITLE_LENGTH = 255
// 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()
// Truncate if too long, or clear if empty
let normalizedTitle: string | undefined
if (sanitized.length === 0) {
normalizedTitle = undefined // Clear empty titles
} else if (sanitized.length > MAX_TITLE_LENGTH) {
normalizedTitle = sanitized.slice(0, MAX_TITLE_LENGTH).trim() // Truncate and trim
console.warn(
`[setTaskTitle] Title truncated from ${sanitized.length} to ${MAX_TITLE_LENGTH} chars for task(s): ${ids.join(", ")}`,
)
} else {
normalizedTitle = sanitized // Use as-is
}

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
Expand Down Expand Up @@ -1590,7 +1642,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
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export type ExtensionState = Pick<
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "taskTitlesEnabled"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down Expand Up @@ -330,6 +331,7 @@ export type ExtensionState = Pick<
renderContext: "sidebar" | "editor"
settingsImportedAt?: number
historyPreviewCollapsed?: boolean
taskTitlesEnabled?: boolean

cloudUserInfo: CloudUserInfo | null
cloudIsAuthenticated: boolean
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface WebviewMessage {
| "importSettings"
| "exportSettings"
| "resetState"
| "setTaskTitle"
| "flushRouterModels"
| "requestRouterModels"
| "requestOpenAiModels"
Expand Down
Loading
Loading