refactor: extract setTaskTitle handler to separate module

This commit is contained in:
Roo Code 2026-01-19 14:56:35 +00:00
parent 9ef7b72d70
commit 1440281ee4
3 changed files with 108 additions and 55 deletions

View file

@ -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()
}
}

View file

@ -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

View file

@ -264,7 +264,7 @@ const TaskHeader = ({
}}
aria-label={t(tooltipKey)}
data-testid="task-title-edit-button">
<Pencil size={16} />
<Pencil size={16} className="opacity-0 group-hover:opacity-100" />
</button>
</StandardTooltip>
)
@ -285,7 +285,7 @@ const TaskHeader = ({
if (currentTitle.length > 0) {
return (
<span className="truncate text-base" data-testid="task-title-text">
<span className="block truncate text-base" data-testid="task-title-text">
{currentTitle}
</span>
)
@ -389,8 +389,7 @@ const TaskHeader = ({
)}
</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">