diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 9a17834ced7..a56a125d08c 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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(), diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index b4d84cb9a51..2bb79be1bcd 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -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(), diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6153af6160c..0d480a10aa7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1960,6 +1960,7 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, enterBehavior, + taskTitlesEnabled, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, @@ -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, @@ -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, @@ -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) } diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 3f820aace15..8c76ba08a2c 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -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() + }) + }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 923011e8ca6..82a485c5c60 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -10,6 +10,7 @@ import { type Language, type GlobalState, type ClineMessage, + type HistoryItem, type TelemetrySetting, type UserSettingsConfig, TelemetryEventName, @@ -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 @@ -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") ?? {} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c88..0dffdb56d26 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -290,6 +290,7 @@ export type ExtensionState = Pick< | "includeCurrentCost" | "maxGitStatusFiles" | "requestDelaySeconds" + | "taskTitlesEnabled" > & { version: string clineMessages: ClineMessage[] @@ -330,6 +331,7 @@ export type ExtensionState = Pick< renderContext: "sidebar" | "editor" settingsImportedAt?: number historyPreviewCollapsed?: boolean + taskTitlesEnabled?: boolean cloudUserInfo: CloudUserInfo | null cloudIsAuthenticated: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4c3e321dea8..81dc1d461b8 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -54,6 +54,7 @@ export interface WebviewMessage { | "importSettings" | "exportSettings" | "resetState" + | "setTaskTitle" | "flushRouterModels" | "requestRouterModels" | "requestOpenAiModels" diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index de499b9aade..328c0ace3ea 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -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" @@ -61,7 +64,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) @@ -93,6 +102,103 @@ const TaskHeader = ({ return () => clearTimeout(timer) }, [currentTaskItem, isTaskComplete]) + const [isEditingTitle, setIsEditingTitle] = useState(false) + const [titleInput, setTitleInput] = useState(currentTaskItem?.title ?? "") + const titleInputRef = useRef(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) => { + 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(null) const textRef = useRef(null) const contextWindow = model?.contextWindow || 1 @@ -118,7 +224,96 @@ const TaskHeader = ({ /> ) + const renderTitleEditor = () => ( +
event.stopPropagation()} className="w-full" data-testid="task-title-editor"> + setTitleInput(event.target.value)} + onBlur={handleTitleBlur} + onKeyDown={handleTitleKeyDown} + placeholder={t("chat:task.titlePlaceholder")} + data-testid="task-title-input" + /> +
+ ) + + const renderTitleAction = () => { + if (!taskTitlesEnabled || !currentTaskItem || isEditingTitle) { + return null + } + + const tooltipKey = currentTitle.length > 0 ? "chat:task.editTitle" : "chat:task.addTitle" + + return ( + + + + ) + } + + const renderCollapsedTitleContent = () => { + if (!taskTitlesEnabled || !currentTaskItem) { + return ( + + + + ) + } + + if (isEditingTitle) { + return renderTitleEditor() + } + + if (currentTitle.length > 0) { + return ( + + {currentTitle} + + ) + } + + return ( + + + + ) + } + + const renderExpandedTitleContent = () => { + if (!taskTitlesEnabled || !currentTaskItem) { + return null + } + + if (isEditingTitle) { + return renderTitleEditor() + } + + if (currentTitle.length > 0) { + return ( + + {currentTitle} + + ) + } + + return null + } + const hasTodos = todos && Array.isArray(todos) && todos.length > 0 + const expandedTitleContent = renderExpandedTitleContent() return (
@@ -169,18 +364,27 @@ const TaskHeader = ({
- {isTaskExpanded && {t("chat:task.title")}} - {!isTaskExpanded && ( -
+ {isTaskExpanded ? ( +
+
+ {t("chat:task.title")} + {renderTitleAction()} +
+ {expandedTitleContent ? ( +
{expandedTitleContent}
+ ) : null} +
+ ) : ( +
- - - +
{renderCollapsedTitleContent()}
+ {renderTitleAction()}
)}
e.stopPropagation()}> - +