From 1524d66ee4313cc7780f67bb684b10b1a2b618dd Mon Sep 17 00:00:00 2001 From: Jakob Malmo Date: Mon, 29 Sep 2025 18:10:34 +0200 Subject: [PATCH 1/8] feat: add editable titles to task history --- packages/types/src/history.ts | 1 + packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/ClineProvider.ts | 16 +- .../ClineProvider.sticky-mode.spec.ts | 59 +++++ src/core/webview/webviewMessageHandler.ts | 52 ++++ webview-ui/src/components/chat/TaskHeader.tsx | 222 +++++++++++++++++- .../chat/__tests__/TaskHeader.spec.tsx | 43 +++- .../src/components/history/TaskItem.tsx | 21 ++ .../history/__tests__/TaskItem.spec.tsx | 15 ++ .../history/__tests__/useTaskSearch.spec.tsx | 15 ++ .../src/components/history/useTaskSearch.ts | 22 +- webview-ui/src/i18n/locales/en/chat.json | 3 + 12 files changed, 448 insertions(+), 22 deletions(-) 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/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 86d8b2ddbbe..a06ccc45d18 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -389,6 +389,7 @@ export interface WebviewMessage { | "importSettings" | "exportSettings" | "resetState" + | "setTaskTitle" | "flushRouterModels" | "requestRouterModels" | "requestOpenAiModels" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78c..33dad9226fe 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2479,13 +2479,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 dbeb380d162..4a049079e9c 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, type ModelRecord, @@ -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 diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 5dca11b9634..f5da8e947dc 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" @@ -67,7 +70,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) @@ -99,6 +108,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 @@ -124,7 +230,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 (
@@ -175,18 +370,25 @@ const TaskHeader = ({
- {isTaskExpanded && {t("chat:task.title")}} - {!isTaskExpanded && ( -
+ {isTaskExpanded ? ( +
+
+ {t("chat:task.title")} + {renderTitleAction()} +
+ {expandedTitleContent ?
{expandedTitleContent}
: null} +
+ ) : ( +
- - - +
{renderCollapsedTitleContent()}
+ {renderTitleAction()}
)}
e.stopPropagation()}> - + ) @@ -285,7 +285,7 @@ const TaskHeader = ({ if (currentTitle.length > 0) { return ( - + {currentTitle} ) @@ -389,8 +389,7 @@ const TaskHeader = ({ )}
e.stopPropagation()}> - +