diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 395ec5986f..599ffa4377 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -11,6 +11,7 @@ export const historyItemSchema = z.object({ number: z.number(), ts: z.number(), task: z.string(), + title: z.string().optional(), // User-defined title for the task tokensIn: z.number(), tokensOut: z.number(), cacheWrites: z.number().optional(), diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 469eb68d65..b3c7614e93 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -37,6 +37,7 @@ const mockClineProvider = { getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), + updateTaskHistory: vi.fn(), } as unknown as ClineProvider import { t } from "../../../i18n" @@ -708,3 +709,188 @@ describe("webviewMessageHandler - mcpEnabled", () => { expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) }) + +describe("webviewMessageHandler - updateTaskTitle", () => { + beforeEach(() => { + vi.clearAllMocks() + // Mock getGlobalState to return a task history + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue([ + { + id: "task-1", + ts: 123456789, + task: "Original task text", + title: "Original title", + }, + { + id: "task-2", + ts: 987654321, + task: "Another task", + // No title + }, + ]) + }) + + it("should update task title when task exists", async () => { + // Mock updateTaskHistory to succeed + vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "task-1", + title: "New updated title", + }) + + // Verify updateTaskHistory was called with the updated task + expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({ + id: "task-1", + ts: 123456789, + task: "Original task text", + title: "New updated title", + }) + + // Verify state was posted to webview + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("should clear task title when empty string is provided", async () => { + vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "task-1", + title: "", + }) + + // Verify updateTaskHistory was called with undefined title + expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({ + id: "task-1", + ts: 123456789, + task: "Original task text", + title: undefined, + }) + + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("should add title to task that didn't have one", async () => { + vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "task-2", + title: "Brand new title", + }) + + // Verify updateTaskHistory was called with the new title + expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({ + id: "task-2", + ts: 987654321, + task: "Another task", + title: "Brand new title", + }) + + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("should not update when task is not found", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "non-existent-task", + title: "Some title", + }) + + // Verify updateTaskHistory was NOT called + expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled() + + // State should not be posted either + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should not update when taskId is missing", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + // No taskId provided + title: "Some title", + }) + + // Verify updateTaskHistory was NOT called + expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled() + + // State should not be posted either + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should handle empty task history gracefully", async () => { + // Mock empty task history + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "task-1", + title: "New title", + }) + + // Verify updateTaskHistory was NOT called + expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled() + + // State should not be posted either + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should handle null task history gracefully", async () => { + // Mock null task history + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(null) + + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "task-1", + title: "New title", + }) + + // Verify updateTaskHistory was NOT called + expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled() + + // State should not be posted either + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + it("should trim whitespace from title", async () => { + vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "task-1", + title: " Trimmed Title ", + }) + + // Verify updateTaskHistory was called with trimmed title + expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({ + id: "task-1", + ts: 123456789, + task: "Original task text", + title: "Trimmed Title", + }) + + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("should handle whitespace-only title as empty", async () => { + vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { + type: "updateTaskTitle", + taskId: "task-1", + title: " ", + }) + + // Verify updateTaskHistory was called with undefined (cleared title) + expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({ + id: "task-1", + ts: 123456789, + task: "Original task text", + title: undefined, + }) + + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index af5f9925c3..45765f82f6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3110,5 +3110,34 @@ export const webviewMessageHandler = async ( }) break } + case "updateTaskTitle": { + // Handle task title update + if (message.taskId && message.title !== undefined) { + try { + // Get the current task history + const history = getGlobalState("taskHistory") ?? [] + const taskItem = history.find((item) => item.id === message.taskId) + + if (taskItem) { + // Update the title field - trim whitespace and set to undefined if empty + const trimmedTitle = message.title?.trim() + taskItem.title = trimmedTitle || undefined // Set to undefined if empty string + + // Save the updated task item + await provider.updateTaskHistory(taskItem) + + // Post updated state back to webview + await provider.postStateToWebview() + } else { + provider.log(`Task not found for title update: ${message.taskId}`) + vscode.window.showErrorMessage(t("common:errors.task_not_found")) + } + } catch (error) { + provider.log(`Error updating task title: ${error instanceof Error ? error.message : String(error)}`) + vscode.window.showErrorMessage(t("common:errors.update_task_title")) + } + } + break + } } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 3a613cc1c2..566e2f5bb2 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -97,6 +97,8 @@ "error_deleting_message": "Error deleting message: {{error}}", "error_editing_message": "Error editing message: {{error}}" }, + "task_not_found": "Task not found", + "update_task_title": "Failed to update task title", "gemini": { "generate_stream": "Gemini generate context stream error: {{error}}", "generate_complete_prompt": "Gemini completion error: {{error}}", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 66f389f81c..90049bca87 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -126,8 +126,11 @@ export interface ExtensionMessage { | "insertTextIntoTextarea" | "dismissedUpsells" | "organizationSwitchResult" + | "taskTitleUpdated" text?: string payload?: any // Add a generic payload for now, can refine later + taskId?: string // For task title updates + title?: string // For task title updates action?: | "chatButtonClicked" | "mcpButtonClicked" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d43a2fce04..38a4c1ba45 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -61,6 +61,7 @@ export interface WebviewMessage { | "showTaskWithId" | "deleteTaskWithId" | "exportTaskWithId" + | "updateTaskTitle" | "importSettings" | "exportSettings" | "resetState" @@ -235,6 +236,8 @@ export interface WebviewMessage { disabled?: boolean context?: string dataUri?: string + taskId?: string // For task title updates + title?: string // For task title updates askResponse?: ClineAskResponse apiConfiguration?: ProviderSettings images?: string[] diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index aef0bc5eee..04abce40a7 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog" import DismissibleUpsell from "@src/components/common/DismissibleUpsell" -import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react" +import { FoldVertical, ChevronUp, ChevronDown, Edit2, Check, X } from "lucide-react" import prettyBytes from "pretty-bytes" import type { ClineMessage } from "@roo-code/types" @@ -16,6 +16,7 @@ import { cn } from "@src/lib/utils" import { StandardTooltip } from "@src/components/ui" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" +import { vscode } from "@src/utils/vscode" import Thumbnails from "../common/Thumbnails" @@ -54,6 +55,9 @@ const TaskHeader = ({ const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) + const [isEditingTitle, setIsEditingTitle] = useState(false) + const [editedTitle, setEditedTitle] = useState(currentTaskItem?.title || "") + const titleInputRef = useRef(null) const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({ autoOpenOnAuth: false, }) @@ -82,6 +86,36 @@ const TaskHeader = ({ return () => clearTimeout(timer) }, [currentTaskItem, isTaskComplete]) + // Update edited title when currentTaskItem changes + useEffect(() => { + setEditedTitle(currentTaskItem?.title || "") + }, [currentTaskItem?.title]) + + // Focus input when editing starts + useEffect(() => { + if (isEditingTitle && titleInputRef.current) { + titleInputRef.current.focus() + titleInputRef.current.select() + } + }, [isEditingTitle]) + + const handleSaveTitle = () => { + if (currentTaskItem) { + // Send message to update the task title + vscode.postMessage({ + type: "updateTaskTitle", + taskId: currentTaskItem.id, + title: editedTitle.trim(), + }) + } + setIsEditingTitle(false) + } + + const handleCancelEdit = () => { + setEditedTitle(currentTaskItem?.title || "") + setIsEditingTitle(false) + } + const textContainerRef = useRef(null) const textRef = useRef(null) const contextWindow = model?.contextWindow || 1 @@ -143,11 +177,65 @@ const TaskHeader = ({
- {isTaskExpanded && {t("chat:task.title")}} + {isTaskExpanded && ( +
+ {isEditingTitle ? ( +
e.stopPropagation()}> + setEditedTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSaveTitle() + } else if (e.key === "Escape") { + handleCancelEdit() + } + }} + className="px-2 py-0.5 bg-vscode-input-background border border-vscode-input-border rounded text-vscode-input-foreground" + placeholder={t("chat:task.titlePlaceholder")} + /> + + + + + + +
+ ) : ( + <> + + {currentTaskItem?.title || t("chat:task.title")} + + + + + + )} +
+ )} {!isTaskExpanded && ( -
- {t("chat:task.title")} - +
+ + {currentTaskItem?.title || t("chat:task.title")} + + {!currentTaskItem?.title && }
)}
diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 6cdbeaf0c6..4146ef7fe5 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -35,7 +35,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ // Create a variable to hold the mock state let mockExtensionState: { apiConfiguration: ProviderSettings - currentTaskItem: { id: string } | null + currentTaskItem: { id: string; title?: string } | null clineMessages: any[] } = { apiConfiguration: { @@ -357,4 +357,223 @@ describe("TaskHeader", () => { expect(screen.getByTestId("dismissible-upsell")).toBeInTheDocument() }) }) + + describe("Title editing functionality", () => { + beforeEach(() => { + // Reset the mock state before each test + mockExtensionState = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id", title: "Existing Title" }, + clineMessages: [], + } + // Clear mock calls + vi.clearAllMocks() + }) + + it("should display the title if it exists", () => { + renderTaskHeader() + expect(screen.getByText("Existing Title")).toBeInTheDocument() + }) + + it("should display task text when no title exists", () => { + mockExtensionState.currentTaskItem = { id: "test-task-id" } + renderTaskHeader() + expect(screen.getByText("Test task")).toBeInTheDocument() + }) + + it("should show edit button when hovering over title", () => { + renderTaskHeader() + const titleElement = screen.getByText("Existing Title") + + // Initially, edit button should not be visible + expect(screen.queryByLabelText("chat:editTitle")).not.toBeInTheDocument() + + // Hover over the title + fireEvent.mouseEnter(titleElement.parentElement!) + + // Edit button should now be visible + expect(screen.getByLabelText("chat:editTitle")).toBeInTheDocument() + }) + + it("should enter edit mode when edit button is clicked", () => { + renderTaskHeader() + const titleElement = screen.getByText("Existing Title") + + // Hover and click edit button + fireEvent.mouseEnter(titleElement.parentElement!) + const editButton = screen.getByLabelText("chat:editTitle") + fireEvent.click(editButton) + + // Should show input field with current title + const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement + expect(input).toBeInTheDocument() + expect(input.tagName).toBe("INPUT") + + // Should show save and cancel buttons + expect(screen.getByLabelText("chat:saveTitle")).toBeInTheDocument() + expect(screen.getByLabelText("chat:cancelEdit")).toBeInTheDocument() + }) + + it("should save title when save button is clicked", () => { + const { vscode } = require("@/utils/vscode") + renderTaskHeader() + + // Enter edit mode + const titleElement = screen.getByText("Existing Title") + fireEvent.mouseEnter(titleElement.parentElement!) + fireEvent.click(screen.getByLabelText("chat:editTitle")) + + // Change the title + const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement + fireEvent.change(input, { target: { value: "New Title" } }) + + // Click save + fireEvent.click(screen.getByLabelText("chat:saveTitle")) + + // Should post message to update title + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateTaskTitle", + taskId: "test-task-id", + title: "New Title", + }) + }) + + it("should cancel editing when cancel button is clicked", () => { + renderTaskHeader() + + // Enter edit mode + const titleElement = screen.getByText("Existing Title") + fireEvent.mouseEnter(titleElement.parentElement!) + fireEvent.click(screen.getByLabelText("chat:editTitle")) + + // Change the title + const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement + fireEvent.change(input, { target: { value: "Changed Title" } }) + + // Click cancel + fireEvent.click(screen.getByLabelText("chat:cancelEdit")) + + // Should exit edit mode and show original title + expect(screen.getByText("Existing Title")).toBeInTheDocument() + expect(screen.queryByDisplayValue("Changed Title")).not.toBeInTheDocument() + }) + + it("should save title when Enter key is pressed", () => { + const { vscode } = require("@/utils/vscode") + renderTaskHeader() + + // Enter edit mode + const titleElement = screen.getByText("Existing Title") + fireEvent.mouseEnter(titleElement.parentElement!) + fireEvent.click(screen.getByLabelText("chat:editTitle")) + + // Change the title and press Enter + const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement + fireEvent.change(input, { target: { value: "Enter Key Title" } }) + fireEvent.keyDown(input, { key: "Enter" }) + + // Should post message to update title + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateTaskTitle", + taskId: "test-task-id", + title: "Enter Key Title", + }) + }) + + it("should cancel editing when Escape key is pressed", () => { + renderTaskHeader() + + // Enter edit mode + const titleElement = screen.getByText("Existing Title") + fireEvent.mouseEnter(titleElement.parentElement!) + fireEvent.click(screen.getByLabelText("chat:editTitle")) + + // Change the title and press Escape + const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement + fireEvent.change(input, { target: { value: "Escape Test" } }) + fireEvent.keyDown(input, { key: "Escape" }) + + // Should exit edit mode and show original title + expect(screen.getByText("Existing Title")).toBeInTheDocument() + expect(screen.queryByDisplayValue("Escape Test")).not.toBeInTheDocument() + }) + + it("should clear title when empty string is saved", () => { + const { vscode } = require("@/utils/vscode") + renderTaskHeader() + + // Enter edit mode + const titleElement = screen.getByText("Existing Title") + fireEvent.mouseEnter(titleElement.parentElement!) + fireEvent.click(screen.getByLabelText("chat:editTitle")) + + // Clear the title + const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement + fireEvent.change(input, { target: { value: "" } }) + + // Click save + fireEvent.click(screen.getByLabelText("chat:saveTitle")) + + // Should post message with empty title + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateTaskTitle", + taskId: "test-task-id", + title: "", + }) + }) + + it("should show placeholder when no title exists and in edit mode", () => { + mockExtensionState.currentTaskItem = { id: "test-task-id" } + renderTaskHeader() + + // Enter edit mode + const taskElement = screen.getByText("Test task") + fireEvent.mouseEnter(taskElement.parentElement!) + fireEvent.click(screen.getByLabelText("chat:editTitle")) + + // Should show input with placeholder + const input = screen.getByPlaceholderText("chat:titlePlaceholder") as HTMLInputElement + expect(input).toBeInTheDocument() + expect(input.value).toBe("") + }) + + it("should not show edit button when task is null", () => { + mockExtensionState.currentTaskItem = null + renderTaskHeader() + + const taskElement = screen.getByText("Test task") + fireEvent.mouseEnter(taskElement.parentElement!) + + // Edit button should not be present + expect(screen.queryByLabelText("chat:editTitle")).not.toBeInTheDocument() + }) + + it("should trim whitespace from saved title", () => { + const { vscode } = require("@/utils/vscode") + renderTaskHeader() + + // Enter edit mode + const titleElement = screen.getByText("Existing Title") + fireEvent.mouseEnter(titleElement.parentElement!) + fireEvent.click(screen.getByLabelText("chat:editTitle")) + + // Add title with whitespace + const input = screen.getByDisplayValue("Existing Title") as HTMLInputElement + fireEvent.change(input, { target: { value: " Trimmed Title " } }) + + // Click save + fireEvent.click(screen.getByLabelText("chat:saveTitle")) + + // Should post message with trimmed title + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateTaskTitle", + taskId: "test-task-id", + title: "Trimmed Title", + }) + }) + }) }) diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx index d661d99930..b768355e7d 100644 --- a/webview-ui/src/components/history/TaskItem.tsx +++ b/webview-ui/src/components/history/TaskItem.tsx @@ -68,11 +68,22 @@ const TaskItem = ({ )}
+ {/* Display title if it exists */} + {item.title && ( +
+ {item.title} +
+ )}