diff --git a/src/core/webview/taskTitleHandler.ts b/src/core/webview/taskTitleHandler.ts new file mode 100644 index 0000000000..a3ded49081 --- /dev/null +++ b/src/core/webview/taskTitleHandler.ts @@ -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 { + // 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() + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index eb84f43bec..cfa96fa0cd 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -10,7 +10,6 @@ import { type Language, type GlobalState, type ClineMessage, - type HistoryItem, type TelemetrySetting, type UserSettingsConfig, type ModelRecord, @@ -33,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" @@ -729,57 +729,9 @@ 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() - } - + case "setTaskTitle": + await handleSetTaskTitle(provider, message) break - } case "showTaskWithId": provider.showTaskWithId(message.text!) break diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index fe8809828f..be88c50e23 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -264,7 +264,7 @@ const TaskHeader = ({ }} aria-label={t(tooltipKey)} data-testid="task-title-edit-button"> - + ) @@ -285,7 +285,7 @@ const TaskHeader = ({ if (currentTitle.length > 0) { return ( - + {currentTitle} ) @@ -389,8 +389,7 @@ const TaskHeader = ({ )}
e.stopPropagation()}> - +