diff --git a/packages/types/src/__tests__/history.test.ts b/packages/types/src/__tests__/history.test.ts index 75fcf1d86c..883bb138d2 100644 --- a/packages/types/src/__tests__/history.test.ts +++ b/packages/types/src/__tests__/history.test.ts @@ -44,3 +44,23 @@ describe("pendingTaskActionSchema", () => { expect(parsed.pendingAction?.actionId).toBe("finish-1") }) }) + +describe("historyItemSchema", () => { + const baseItem = { + id: "task-1", + number: 1, + ts: 1, + task: "Original prompt", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + + it("keeps a user-assigned title", () => { + expect(historyItemSchema.parse({ ...baseItem, title: "Renamed" }).title).toBe("Renamed") + }) + + it("leaves title unset when absent", () => { + expect(historyItemSchema.parse(baseItem)).not.toHaveProperty("title") + }) +}) diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 5d9671842e..131194212e 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -33,6 +33,7 @@ export const historyItemSchema = z.object({ number: z.number(), ts: z.number(), task: z.string(), + title: z.string().optional(), // User-assigned display name; `task` keeps the original prompt tokensIn: z.number(), tokensOut: z.number(), cacheWrites: z.number().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a5393ddc66..dc407325d9 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -502,6 +502,7 @@ export interface WebviewMessage { | "deleteTaskWithId" | "abandonSubtaskWithId" | "exportTaskWithId" + | "renameTaskWithId" | "importSettings" | "exportSettings" | "resetState" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ff18465f09..eafd902eb2 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2450,6 +2450,33 @@ export class ClineProvider } } + /* + * Sets a user-assigned display title for a history item. A blank title + * clears it so the history falls back to the original prompt. + */ + async renameTaskWithId(id: string, title: string | undefined) { + if (!this.taskHistoryStore.get(id)) { + this.log(`[renameTaskWithId] task ${id} not found in history`) + return + } + + // Read and write under the store lock so a queued task save or delegation + // transition cannot be overwritten by a stale snapshot of the item. + const trimmed = title?.trim() + await this.taskHistoryStore.atomicReadAndUpdate(id, (historyItem) => ({ + ...historyItem, + title: trimmed ? trimmed : undefined, + })) + this.recentTasksCache = undefined + + if (this.isViewLaunched) { + const updatedItem = this.taskHistoryStore.get(id) + if (updatedItem) { + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) + } + } + } + /* Condenses a task's message history to use fewer tokens. */ async condenseTaskContext(taskId: string) { const task = this.taskRegistry.getById(taskId) @@ -3247,6 +3274,10 @@ export class ClineProvider * Updates a task in the task history and optionally broadcasts the updated history to the webview. * Now delegates to TaskHistoryStore for per-task file persistence. * + * This writer never owns the `title` field: `title` is written only by the rename path + * (`renameTaskWithId` via `taskHistoryStore.atomicReadAndUpdate`). Callers here build items + * from snapshots read before taking the store lock, so any title they carry can be stale. + * * @param item The history item to update or add * @param options.broadcast Whether to broadcast the updated history to the webview (default: true) * @returns The updated task history array @@ -3254,13 +3285,17 @@ export class ClineProvider async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise { const { broadcast = true } = options - const history = await this.taskHistoryStore.upsert(item) + // Drop the snapshot's title so upsertCore preserves the value held under the store lock + // instead of treating a stale title as a changed field and reverting a concurrent rename. + const { title: _title, ...itemWithoutTitle } = item + + const history = await this.taskHistoryStore.upsert(itemWithoutTitle) this.recentTasksCache = undefined // Broadcast the updated history to the webview if requested. // Prefer per-item updates to avoid repeatedly cloning/sending the full history. if (broadcast && this.isViewLaunched) { - const updatedItem = this.taskHistoryStore.get(item.id) ?? item + const updatedItem = this.taskHistoryStore.get(item.id) ?? itemWithoutTitle await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) } diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index bec8a31040..30d635df4b 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -597,6 +597,100 @@ describe("ClineProvider Task History Synchronization", () => { }) }) + describe("renameTaskWithId", () => { + const getStored = (id: string) => provider.taskHistoryStore.get(id) + + it("stores a trimmed title and broadcasts the updated item", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + await provider.updateTaskHistory(createHistoryItem({ id: "task-rename", task: "Original prompt" })) + mockPostMessage.mockClear() + + await provider.renameTaskWithId("task-rename", " Renamed ") + + expect(getStored("task-rename")).toMatchObject({ title: "Renamed", task: "Original prompt" }) + const updates = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated") + expect(updates.at(-1)?.[0].taskHistoryItem).toMatchObject({ id: "task-rename", title: "Renamed" }) + }) + + it.each([ + ["an empty string", ""], + ["whitespace", " "], + ["undefined", undefined], + ])("clears the title when given %s", async (_label, title) => { + // Set the starting title through the rename path: updateTaskHistory drops `title`. + await provider.updateTaskHistory(createHistoryItem({ id: "task-clear", task: "Original prompt" }), { + broadcast: false, + }) + await provider.renameTaskWithId("task-clear", "Old title") + expect(getStored("task-clear")?.title).toBe("Old title") + + await provider.renameTaskWithId("task-clear", title) + + expect(getStored("task-clear")?.title).toBeUndefined() + expect(getStored("task-clear")?.task).toBe("Original prompt") + }) + + it("keeps the title when the task later saves metadata without one", async () => { + const item = createHistoryItem({ id: "task-keep", task: "Original prompt", status: "active" }) + await provider.updateTaskHistory(item, { broadcast: false }) + await provider.renameTaskWithId("task-keep", "Renamed") + + // Mirrors Task.saveClineMessages(), whose taskMetadata() result never carries a title. + await provider.updateTaskHistory({ ...item, tokensIn: 500 }, { broadcast: false }) + + expect(getStored("task-keep")).toMatchObject({ title: "Renamed", tokensIn: 500 }) + }) + + it("keeps a concurrent task save that is queued before the rename", async () => { + const item = createHistoryItem({ id: "task-race", task: "Original prompt", status: "active" }) + await provider.updateTaskHistory(item, { broadcast: false }) + + // Start the save without awaiting it, so the rename runs while the save is queued. + const save = provider.updateTaskHistory({ ...item, tokensIn: 500 }, { broadcast: false }) + const rename = provider.renameTaskWithId("task-race", "Renamed") + await Promise.all([save, rename]) + + expect(getStored("task-race")).toMatchObject({ title: "Renamed", tokensIn: 500 }) + }) + + it("does not write a title through a full-item update", async () => { + await provider.updateTaskHistory( + createHistoryItem({ id: "task-title-owner", task: "Original prompt", title: "Stale title" }), + { broadcast: false }, + ) + + expect(getStored("task-title-owner")).toMatchObject({ id: "task-title-owner", task: "Original prompt" }) + expect(getStored("task-title-owner")?.title).toBeUndefined() + }) + + it("does not revert a rename when a full-item update from an older snapshot lands", async () => { + const item = createHistoryItem({ id: "task-stale-write", task: "Original prompt", status: "active" }) + await provider.updateTaskHistory(item, { broadcast: false }) + + // A prior rename leaves a title on the record; a caller reading the item now + // (e.g. onTaskCompleted, mode or profile switch) captures it in a pre-lock snapshot. + await provider.renameTaskWithId("task-stale-write", "Old title") + const staleSnapshot = { ...item, title: "Old title" } + + // A queued rename completes after the snapshot was taken. + await provider.renameTaskWithId("task-stale-write", "Renamed") + + await provider.updateTaskHistory({ ...staleSnapshot, status: "completed" }, { broadcast: false }) + + expect(getStored("task-stale-write")).toMatchObject({ title: "Renamed", status: "completed" }) + }) + + it("does nothing for an unknown task", async () => { + const logSpy = vi.spyOn(provider, "log") + + await provider.renameTaskWithId("missing-task", "Renamed") + + expect(getStored("missing-task")).toBeUndefined() + expect(logSpy).toHaveBeenCalledWith("[renameTaskWithId] task missing-task not found in history") + }) + }) + describe("broadcastTaskHistoryUpdate", () => { it("sends taskHistoryUpdated message with sorted history", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d5c72de742..685a7b11c1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -132,6 +132,7 @@ const mockClineProvider = { getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), + renameTaskWithId: vi.fn(), getSkillsManager: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -3036,3 +3037,19 @@ describe("updateProfileModel", () => { expect(updateProfileModel).not.toHaveBeenCalled() }) }) + +describe("webviewMessageHandler - renameTaskWithId", () => { + beforeEach(() => { + vi.mocked(mockClineProvider.renameTaskWithId).mockClear() + }) + + it.each([ + ["forwards taskId and text", { taskId: "task-1", text: "New name" }, [["task-1", "New name"]]], + ["forwards an empty title so the provider can clear it", { taskId: "task-1", text: "" }, [["task-1", ""]]], + ["skips the call when taskId is missing", { text: "New name" }, []], + ])("%s", async (_label, payload, expectedCalls) => { + await webviewMessageHandler(mockClineProvider, { type: "renameTaskWithId", ...payload }) + + expect(vi.mocked(mockClineProvider.renameTaskWithId).mock.calls).toEqual(expectedCalls) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5596b90e67..bd7c589958 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -981,6 +981,11 @@ export const webviewMessageHandler = async ( case "exportTaskWithId": await provider.exportTaskWithId(message.text!) break + case "renameTaskWithId": + if (message.taskId) { + await provider.renameTaskWithId(message.taskId, message.text) + } + break case "getTaskWithAggregatedCosts": { try { const taskId = message.text diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 90be332890..1d7e2be387 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -168,6 +168,43 @@ export const stories: Record = { ) }, + "history-rename": async () => { + const [{ AppProviders }, { default: HistoryView }] = await Promise.all([ + import("../AppProviders"), + import("@/components/history/HistoryView"), + ]) + // The visual test freezes the clock at this time so "time ago" labels stay stable. + const now = new Date("2026-06-01T12:00:00Z").getTime() + const workspace = "/workspace/demo" + const baseItem = { tokensIn: 1200, tokensOut: 800, totalCost: 0.12, workspace } + return ( + +
+ undefined} /> +
+
+ ) + }, "ignored-disabled-tools-warning": async () => { const [{ AppProviders }, { ChatRowContent }] = await Promise.all([ import("../AppProviders"), diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 283d8d0b67..f3e4b29cc0 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -8,15 +8,24 @@ import { useCopyToClipboard } from "@/utils/clipboard" import { useExtensionState } from "@/context/ExtensionStateContext" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" -import { CopyIcon, CheckIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" +import { + CopyIcon, + CheckIcon, + DownloadIcon, + Trash2Icon, + FileJsonIcon, + MessageSquareCodeIcon, + PencilIcon, +} from "lucide-react" import { LucideIconButton } from "./LucideIconButton" interface TaskActionsProps { item?: HistoryItem buttonsDisabled: boolean + onRename?: () => void } -export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { +export const TaskActions = ({ item, buttonsDisabled, onRename }: TaskActionsProps) => { const [deleteTaskId, setDeleteTaskId] = useState(null) const { t } = useTranslation() const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() @@ -24,6 +33,14 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { return (
+ {item?.id && onRename && ( + + )} (null) const textRef = useRef(null) @@ -179,10 +182,23 @@ const TaskHeader = ({
- {isTaskExpanded && {t("chat:task.title")}} + {isTaskExpanded && + (isRenaming && currentTaskItem ? ( + setIsRenaming(false)} + className="text-sm" + /> + ) : ( + {customTitle || t("chat:task.title")} + ))} {!isTaskExpanded && (
- + {customTitle ? ( + {customTitle} + ) : ( + + )}
)}
@@ -336,7 +352,11 @@ const TaskHeader = ({ {task.images && task.images.length > 0 && }
e.stopPropagation()}> - + setIsRenaming(true)} + />
diff --git a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx index 4b096817db..2e4be8d561 100644 --- a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx @@ -83,6 +83,21 @@ describe("TaskActions", () => { expect(screen.queryByTestId("share-button")).not.toBeInTheDocument() }) + it("calls onRename when the rename button is clicked", () => { + const onRename = vi.fn() + render() + + fireEvent.click(screen.getByLabelText("history:renameTask")) + + expect(onRename).toHaveBeenCalledTimes(1) + }) + + it("does not render the rename button without onRename", () => { + render() + + expect(screen.queryByTestId("rename-current-task-button")).not.toBeInTheDocument() + }) + it("renders export button", () => { render() diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 022186f40e..363a2b6aae 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -38,7 +38,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ // Create a variable to hold the mock state const mockExtensionState: { apiConfiguration: ProviderSettings - currentTaskItem: { id: string } | null + currentTaskItem: { id: string; task?: string; title?: string } | null clineMessages: any[] taskHistory: any[] } = { @@ -272,6 +272,68 @@ describe("TaskHeader", () => { }) }) + describe("Rename", () => { + beforeEach(() => { + mockPostMessage.mockClear() + mockExtensionState.currentTaskItem = { id: "test-task-id", task: "Test task" } + }) + + afterEach(() => { + mockExtensionState.currentTaskItem = { id: "test-task-id" } + }) + + it("shows the custom title instead of the first message when the task is renamed", () => { + mockExtensionState.currentTaskItem = { id: "test-task-id", task: "Test task", title: "My title" } + renderTaskHeader() + + expect(screen.getByText("My title")).toBeInTheDocument() + expect(screen.queryByText("Test task")).not.toBeInTheDocument() + }) + + it.each([ + ["the custom title", "My title", "My title"], + ["the default label without a custom title", undefined, "chat:task.title"], + ])("shows %s as the expanded heading", (_label, title, expected) => { + mockExtensionState.currentTaskItem = { id: "test-task-id", task: "Test task", title } + renderTaskHeader() + fireEvent.click(screen.getByText(title ?? "Test task")) + + expect(screen.getByText(expected)).toHaveClass("font-bold") + expect(screen.queryByTestId("task-title-input")).not.toBeInTheDocument() + }) + + it("closes the header editor on Escape without saving", () => { + renderTaskHeader() + fireEvent.click(screen.getByText("Test task")) + fireEvent.click(screen.getByTestId("rename-current-task-button")) + + fireEvent.keyDown(screen.getByTestId("task-title-input"), { key: "Escape" }) + + expect(screen.queryByTestId("task-title-input")).not.toBeInTheDocument() + expect(screen.getByText("chat:task.title")).toBeInTheDocument() + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("renames from the expanded header without collapsing it", () => { + renderTaskHeader() + fireEvent.click(screen.getByText("Test task")) + + fireEvent.click(screen.getByTestId("rename-current-task-button")) + const input = screen.getByTestId("task-title-input") + fireEvent.click(input) + fireEvent.change(input, { target: { value: "Renamed" } }) + fireEvent.keyDown(input, { key: "Enter" }) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "renameTaskWithId", + taskId: "test-task-id", + text: "Renamed", + }) + expect(screen.queryByTestId("task-title-input")).not.toBeInTheDocument() + expect(screen.getByTestId("rename-current-task-button")).toBeInTheDocument() + }) + }) + describe("Context window percentage calculation", () => { // The percentage should be calculated as: // contextTokens / (contextWindow - reservedForOutput) * 100 diff --git a/webview-ui/src/components/history/RenameButton.tsx b/webview-ui/src/components/history/RenameButton.tsx new file mode 100644 index 0000000000..bd834525d2 --- /dev/null +++ b/webview-ui/src/components/history/RenameButton.tsx @@ -0,0 +1,28 @@ +import { Button, StandardTooltip } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" + +type RenameButtonProps = { + onRename: () => void +} + +export const RenameButton = ({ onRename }: RenameButtonProps) => { + const { t } = useAppTranslation() + const label = t("history:renameTask") + + return ( + + + + ) +} diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index c0aa88489a..1e110ed195 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/webview-ui/src/components/history/SubtaskRow.tsx @@ -3,7 +3,7 @@ import { ArrowRight } from "lucide-react" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" import type { SubtaskTreeNode } from "./types" -import { countAllSubtasks } from "./types" +import { countAllSubtasks, getTaskDisplayTitle } from "./types" import { StandardTooltip } from "../ui" import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" import { TaskStatusBadge } from "./TaskStatusBadge" @@ -51,7 +51,7 @@ const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) } }}> - {item.task} + {getTaskDisplayTitle(item)} {(item.status === "delegated" || item.status === "interrupted") && ( diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx index eba5e59ac9..f9cb85f360 100644 --- a/webview-ui/src/components/history/TaskItem.tsx +++ b/webview-ui/src/components/history/TaskItem.tsx @@ -1,12 +1,13 @@ -import { memo } from "react" +import { memo, useState } from "react" import { ArrowRight, Folder } from "lucide-react" -import type { DisplayHistoryItem } from "./types" +import { getTaskDisplayTitle, type DisplayHistoryItem } from "./types" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" import { Checkbox } from "@/components/ui/checkbox" import TaskItemFooter from "./TaskItemFooter" +import { TaskTitleEditor } from "./TaskTitleEditor" import { StandardTooltip } from "../ui" interface TaskItemProps { @@ -32,7 +33,13 @@ const TaskItem = ({ onDelete, className, }: TaskItemProps) => { + const [isRenaming, setIsRenaming] = useState(false) + const handleClick = () => { + if (isRenaming) { + return + } + if (isSelectionMode && onToggleSelection) { onToggleSelection(item.id, !isSelected) } else { @@ -71,7 +78,9 @@ const TaskItem = ({
- {item.highlight ? ( + {isRenaming ? ( + setIsRenaming(false)} /> + ) : item.highlight ? (
- {item.task} + {getTaskDisplayTitle(item)}
)} @@ -115,6 +124,7 @@ const TaskItem = ({ isSelectionMode={isSelectionMode} isSubtask={item.isSubtask} onDelete={onDelete} + onRename={isRenaming ? undefined : () => setIsRenaming(true)} />
diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index 72c6b64420..5d75822f4c 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -4,6 +4,7 @@ import { formatTimeAgo } from "@/utils/format" import { CopyButton } from "./CopyButton" import { ExportButton } from "./ExportButton" import { DeleteButton } from "./DeleteButton" +import { RenameButton } from "./RenameButton" import { StandardTooltip } from "../ui/standard-tooltip" import { useAppTranslation } from "@/i18n/TranslationContext" import { Split } from "lucide-react" @@ -15,6 +16,7 @@ export interface TaskItemFooterProps { isSelectionMode?: boolean isSubtask?: boolean onDelete?: (taskId: string) => void + onRename?: () => void } const TaskItemFooter: React.FC = ({ @@ -23,6 +25,7 @@ const TaskItemFooter: React.FC = ({ isSelectionMode = false, isSubtask = false, onDelete, + onRename, }) => { const { t } = useAppTranslation() @@ -63,6 +66,7 @@ const TaskItemFooter: React.FC = ({ {/* Action Buttons for non-compact view */} {!isSelectionMode && (
+ {variant === "full" && onRename && } {variant === "full" && } {onDelete && } diff --git a/webview-ui/src/components/history/TaskTitleEditor.tsx b/webview-ui/src/components/history/TaskTitleEditor.tsx new file mode 100644 index 0000000000..c02e044311 --- /dev/null +++ b/webview-ui/src/components/history/TaskTitleEditor.tsx @@ -0,0 +1,81 @@ +import { useCallback, useState } from "react" +import type { HistoryItem } from "@roo-code/types" + +import { AutosizeTextarea } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { cn } from "@/lib/utils" +import { vscode } from "@/utils/vscode" + +import { getTaskDisplayTitle } from "./types" + +type TaskTitleEditorProps = { + item: Pick + onDone: () => void + className?: string +} + +// Titles are a single line: line breaks and repeated whitespace collapse to one space. +const normalizeTitle = (text: string) => text.replace(/\s+/g, " ").trim() + +/** + * Inline editor for renaming a history item. Enter saves, Escape or blur cancels. + * Saving a blank value, or the original prompt, clears the custom title. + * + * It wraps instead of scrolling sideways so a long title stays readable at narrow widths. + */ +export const TaskTitleEditor = ({ item, onDone, className }: TaskTitleEditorProps) => { + const { t } = useAppTranslation() + const [value, setValue] = useState(() => normalizeTitle(getTaskDisplayTitle(item))) + + const save = useCallback(() => { + const normalized = normalizeTitle(value) + const nextTitle = normalized && normalized !== normalizeTitle(item.task) ? normalized : undefined + + if (nextTitle !== item.title) { + vscode.postMessage({ type: "renameTaskWithId", taskId: item.id, text: nextTitle ?? "" }) + } + + onDone() + }, [value, item, onDone]) + + // Let keydown keep bubbling: VS Code's webview host listens on `window` to run + // clipboard and undo shortcuts, so stopping propagation breaks paste (Cmd/Ctrl+V). + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + // IME users press Enter to confirm a conversion candidate, not to save. + if (e.nativeEvent.isComposing || e.keyCode === 229) { + return + } + e.preventDefault() + save() + } else if (e.key === "Escape") { + e.preventDefault() + onDone() + } + }, + [save, onDone], + ) + + return ( + e.currentTarget.select()} + onChange={(e) => setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={onDone} + onClick={(e) => e.stopPropagation()} + className={cn( + "flex-1 min-w-0 resize-none border rounded-md px-2 py-1 text-base select-text break-words", + className, + )} + /> + ) +} diff --git a/webview-ui/src/components/history/__tests__/HistoryView.visual.tsx b/webview-ui/src/components/history/__tests__/HistoryView.visual.tsx index 878c39f1d6..4c325eaa3a 100644 --- a/webview-ui/src/components/history/__tests__/HistoryView.visual.tsx +++ b/webview-ui/src/components/history/__tests__/HistoryView.visual.tsx @@ -21,4 +21,27 @@ for (const theme of visualThemes) { focusedControl: selectionButton, }) }) + + test(`renders task rename states in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + // Matches the "history-rename" story's reference time so "time ago" labels stay stable. + await page.clock.setFixedTime(new Date("2026-06-01T12:00:00Z")) + const component = mountedStory(await mount("history-rename")) + await applyVisualTheme(page, theme) + + // A renamed task shows its custom title. Hovering a row reveals the rename action. + const renamedTask = component.getByTestId("task-item-task-renamed") + await expect(renamedTask.getByText("Auth refactor")).toBeVisible() + const originalTask = component.getByTestId("task-item-task-original") + await originalTask.getByText("Write unit tests for the billing service").hover() + const renameButton = originalTask.getByTestId("rename-task-button") + await expect(renameButton).toBeVisible() + + // The rename action swaps the title for an inline editor. + await renameButton.click() + const input = originalTask.getByTestId("task-title-input") + await expect(input).toBeFocused() + await expect(input).toHaveValue("Write unit tests for the billing service") + await expect(component).toHaveScreenshot(`history-rename-editor-${theme.name}.png`) + await expectBoundedLayout(page, component, { focusedControl: input }) + }) } diff --git a/webview-ui/src/components/history/__tests__/RenameButton.spec.tsx b/webview-ui/src/components/history/__tests__/RenameButton.spec.tsx new file mode 100644 index 0000000000..2bc837ee12 --- /dev/null +++ b/webview-ui/src/components/history/__tests__/RenameButton.spec.tsx @@ -0,0 +1,26 @@ +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { RenameButton } from "../RenameButton" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe("RenameButton", () => { + it("calls onRename without letting the click reach the task row", () => { + const onRename = vi.fn() + const onRowClick = vi.fn() + render( +
+ +
, + ) + + fireEvent.click(screen.getByRole("button", { name: "history:renameTask" })) + + expect(onRename).toHaveBeenCalledTimes(1) + expect(onRowClick).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx index 6337b9f1fa..4fb609081f 100644 --- a/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx +++ b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx @@ -55,6 +55,15 @@ describe("SubtaskRow", () => { expect(screen.getByText("Leaf task content")).toBeInTheDocument() }) + it("shows the custom title instead of the original prompt when both are set", () => { + const node = createMockNode({ id: "leaf-2", task: "Original prompt", title: "Custom title" }) + + render() + + expect(screen.getByText("Custom title")).toBeInTheDocument() + expect(screen.queryByText("Original prompt")).not.toBeInTheDocument() + }) + it("renders with correct depth indentation", () => { const node = createMockNode({ id: "leaf-1", task: "Indented task" }) diff --git a/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx index df8fc742d3..4fb4a82d72 100644 --- a/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx @@ -1,5 +1,7 @@ import { render, screen, fireEvent } from "@/utils/test-utils" +import { vscode } from "@src/utils/vscode" + import TaskItem from "../TaskItem" vi.mock("@src/utils/vscode") @@ -109,4 +111,39 @@ describe("TaskItem", () => { const taskItem = screen.getByTestId("task-item-1") expect(taskItem).toHaveClass("hover:text-vscode-foreground") }) + + it("shows the custom title instead of the prompt when renamed", () => { + render() + + expect(screen.getByText("My title")).toBeInTheDocument() + expect(screen.queryByText("Test task")).not.toBeInTheDocument() + }) + + it("opens an inline editor from the rename button without opening the task", () => { + render() + + fireEvent.click(screen.getByTestId("rename-task-button")) + + expect(screen.getByTestId("task-title-input")).toHaveValue("Test task") + fireEvent.click(screen.getByTestId("task-title-input")) + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it("saves the new title on Enter and closes the editor", () => { + render() + + fireEvent.click(screen.getByTestId("rename-task-button")) + const input = screen.getByTestId("task-title-input") + fireEvent.change(input, { target: { value: " Renamed task " } }) + fireEvent.keyDown(input, { key: "Enter" }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "renameTaskWithId", taskId: "1", text: "Renamed task" }) + expect(screen.queryByTestId("task-title-input")).not.toBeInTheDocument() + }) + + it("does not show the rename button in the compact variant", () => { + render() + + expect(screen.queryByTestId("rename-task-button")).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx index fc8d7edcca..83047dc0b4 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -114,4 +114,20 @@ describe("TaskItemFooter", () => { expect(screen.queryByTestId(/task-status-badge-/)).not.toBeInTheDocument() }) + + it("shows rename button in full variant when onRename is provided", () => { + render() + + expect(screen.getByTestId("rename-task-button")).toBeInTheDocument() + }) + + it.each([ + ["compact variant", { variant: "compact" as const, onRename: vi.fn() }], + ["selection mode", { variant: "full" as const, onRename: vi.fn(), isSelectionMode: true }], + ["no onRename", { variant: "full" as const }], + ])("hides rename button in %s", (_label, props) => { + render() + + expect(screen.queryByTestId("rename-task-button")).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/components/history/__tests__/TaskTitleEditor.spec.tsx b/webview-ui/src/components/history/__tests__/TaskTitleEditor.spec.tsx new file mode 100644 index 0000000000..951dc85ac8 --- /dev/null +++ b/webview-ui/src/components/history/__tests__/TaskTitleEditor.spec.tsx @@ -0,0 +1,142 @@ +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import { TaskTitleEditor } from "../TaskTitleEditor" + +vi.mock("@src/utils/vscode") + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const renderEditor = (title?: string) => { + const onDone = vi.fn() + render() + return { input: screen.getByTestId("task-title-input"), onDone } +} + +const submit = (input: HTMLElement, value: string) => { + fireEvent.change(input, { target: { value } }) + fireEvent.keyDown(input, { key: "Enter" }) +} + +describe("TaskTitleEditor", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + ["the original prompt when there is no title", undefined, "Original prompt"], + ["the current title when renamed", "Custom", "Custom"], + ])("starts with %s", (_label, title, expected) => { + const { input } = renderEditor(title) + + expect(input).toHaveValue(expected) + }) + + it("sends the trimmed title on Enter", () => { + const { input, onDone } = renderEditor() + + submit(input, " New name ") + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "renameTaskWithId", + taskId: "task-1", + text: "New name", + }) + expect(onDone).toHaveBeenCalled() + }) + + it("collapses line breaks and repeated spaces into one line", () => { + const { input } = renderEditor() + + submit(input, " First line\n\nsecond line ") + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "renameTaskWithId", + taskId: "task-1", + text: "First line second line", + }) + }) + + it("starts with a multi-line prompt shown on one line", () => { + render() + + expect(screen.getByTestId("task-title-input")).toHaveValue("Line one Line two") + }) + + it("does not send a message when a multi-line prompt is saved unchanged", () => { + render() + + fireEvent.keyDown(screen.getByTestId("task-title-input"), { key: "Enter" }) + + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it.each([ + ["a blank value", " "], + ["the original prompt", "Original prompt"], + ])("clears an existing title when saving %s", (_label, value) => { + const { input } = renderEditor("Custom") + + submit(input, value) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "renameTaskWithId", taskId: "task-1", text: "" }) + }) + + it.each([ + ["the title is unchanged", "Custom", "Custom"], + ["an untitled task is saved blank", undefined, ""], + ])("does not send a message when %s", (_label, title, value) => { + const { input, onDone } = renderEditor(title) + + submit(input, value) + + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(onDone).toHaveBeenCalled() + }) + + it.each([ + ["isComposing is set", { isComposing: true }], + ["the IME keyCode 229 is reported", { keyCode: 229 }], + ])("ignores Enter while an IME composition is active (%s)", (_label, imeState) => { + const { input, onDone } = renderEditor() + + fireEvent.change(input, { target: { value: "にほん" } }) + fireEvent.keyDown(input, { key: "Enter", ...imeState }) + + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(onDone).not.toHaveBeenCalled() + expect(input).toBeInTheDocument() + }) + + it("lets keydown reach window so VS Code can run clipboard shortcuts", () => { + const onWindowKeyDown = vi.fn() + window.addEventListener("keydown", onWindowKeyDown) + try { + const { input } = renderEditor() + + fireEvent.keyDown(input, { key: "v", metaKey: true }) + + expect(onWindowKeyDown).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener("keydown", onWindowKeyDown) + } + }) + + it.each([ + ["Escape", (input: HTMLElement) => fireEvent.keyDown(input, { key: "Escape" })], + ["blur", (input: HTMLElement) => fireEvent.blur(input)], + ])("cancels on %s without saving", (_label, cancel) => { + const { input, onDone } = renderEditor() + + fireEvent.change(input, { target: { value: "Discarded" } }) + cancel(input) + + expect(vscode.postMessage).not.toHaveBeenCalled() + expect(onDone).toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-dark.png b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-dark.png new file mode 100644 index 0000000000..2bff5fbfaf Binary files /dev/null and b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-dark.png differ diff --git a/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-high-contrast-light.png b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-high-contrast-light.png new file mode 100644 index 0000000000..1174b66e5b Binary files /dev/null and b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-high-contrast-light.png differ diff --git a/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-high-contrast.png b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-high-contrast.png new file mode 100644 index 0000000000..75cbf52222 Binary files /dev/null and b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-high-contrast.png differ diff --git a/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-light.png b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-light.png new file mode 100644 index 0000000000..7190f6b6c8 Binary files /dev/null and b/webview-ui/src/components/history/__tests__/__screenshots__/history-rename-editor-light.png differ diff --git a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx index bea79814fa..179fa9d3de 100644 --- a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx +++ b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx @@ -13,8 +13,10 @@ vi.mock("@/utils/highlight", () => ({ })) import { useExtensionState } from "@/context/ExtensionStateContext" +import { highlightFzfMatch } from "@/utils/highlight" const mockUseExtensionState = useExtensionState as ReturnType +const mockHighlightFzfMatch = highlightFzfMatch as ReturnType const mockTaskHistory: HistoryItem[] = [ { @@ -154,6 +156,45 @@ describe("useTaskSearch", () => { expect((result.current.tasks[0] as any).highlight).toBe("Create a React component") }) + it("searches and highlights the custom title of a renamed task", () => { + mockUseExtensionState.mockReturnValue({ + taskHistory: [{ ...mockTaskHistory[1], title: "Coverage sprint" }], + cwd: "/workspace/project1", + }) + const { result } = renderHook(() => useTaskSearch()) + + act(() => { + result.current.setSearchQuery("Coverage") + }) + + expect(result.current.tasks).toEqual([ + expect.objectContaining({ id: "task-2", highlight: "Coverage sprint" }), + ]) + }) + + it("keeps match positions from a custom title that is longer than the task", () => { + // "Write unit tests" (16 chars) vs "Coverage sprint with deep tests" (31 chars): + // with the old task.length bound, every position of "deep" (21-24) would be filtered out. + mockUseExtensionState.mockReturnValue({ + taskHistory: [{ ...mockTaskHistory[1], title: "Coverage sprint with deep tests" }], + cwd: "/workspace/project1", + }) + const { result } = renderHook(() => useTaskSearch()) + + act(() => { + result.current.setSearchQuery("deep") + }) + + expect(result.current.tasks).toEqual([ + expect.objectContaining({ id: "task-2", highlight: "Coverage sprint with deep tests" }), + ]) + // fzf reports positions in match order; normalize before comparing so this pins the + // position set (21-24), not fzf's internal ordering. + const lastCall = mockHighlightFzfMatch.mock.calls.at(-1) + expect(lastCall?.[0]).toBe("Coverage sprint with deep tests") + expect([...(lastCall?.[1] ?? [])].sort((a, b) => a - b)).toEqual([21, 22, 23, 24]) + }) + it("automatically switches to mostRelevant when searching", () => { const { result } = renderHook(() => useTaskSearch()) diff --git a/webview-ui/src/components/history/types.ts b/webview-ui/src/components/history/types.ts index 0de5e43081..8eca94abcb 100644 --- a/webview-ui/src/components/history/types.ts +++ b/webview-ui/src/components/history/types.ts @@ -10,6 +10,13 @@ export interface DisplayHistoryItem extends HistoryItem { isSubtask?: boolean } +/** + * The text shown for a task in history: the user-assigned title when set, otherwise the original prompt. + */ +export function getTaskDisplayTitle(item: Pick): string { + return item.title || item.task +} + /** * A node in the subtask tree, representing a task and its recursively nested children. */ diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 3969985b98..9ac00a591e 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -4,6 +4,8 @@ import { Fzf } from "fzf" import { highlightFzfMatch } from "@/utils/highlight" import { useExtensionState } from "@/context/ExtensionStateContext" +import { getTaskDisplayTitle } from "./types" + type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" export const useTaskSearch = () => { @@ -33,7 +35,7 @@ export const useTaskSearch = () => { const fzf = useMemo(() => { return new Fzf(presentableTasks, { - selector: (item) => item.task, + selector: getTaskDisplayTitle, }) }, [presentableTasks]) @@ -44,12 +46,13 @@ export const useTaskSearch = () => { const searchResults = fzf.find(searchQuery) results = searchResults.map((result) => { const positions = Array.from(result.positions) - const taskEndIndex = result.item.task.length + const displayTitle = getTaskDisplayTitle(result.item) + const taskEndIndex = displayTitle.length return { ...result.item, highlight: highlightFzfMatch( - result.item.task, + displayTitle, positions.filter((p) => p < taskEndIndex), ), workspace: result.item.workspace, diff --git a/webview-ui/src/i18n/locales/ca/history.json b/webview-ui/src/i18n/locales/ca/history.json index a872651d23..9277538d30 100644 --- a/webview-ui/src/i18n/locales/ca/history.json +++ b/webview-ui/src/i18n/locales/ca/history.json @@ -20,6 +20,7 @@ "apiCostLabel": "Cost d'API:", "copyPrompt": "Copiar prompt", "exportTask": "Exportar tasca", + "renameTask": "Canvia el nom de la tasca", "deleteTask": "Eliminar tasca", "deleteTaskMessage": "Estàs segur que vols eliminar aquesta tasca? Aquesta acció no es pot desfer.", "cancel": "Cancel·lar", diff --git a/webview-ui/src/i18n/locales/de/history.json b/webview-ui/src/i18n/locales/de/history.json index b10fcd445e..89917ce251 100644 --- a/webview-ui/src/i18n/locales/de/history.json +++ b/webview-ui/src/i18n/locales/de/history.json @@ -20,6 +20,7 @@ "apiCostLabel": "API-Kosten:", "copyPrompt": "Prompt kopieren", "exportTask": "Aufgabe exportieren", + "renameTask": "Aufgabe umbenennen", "deleteTask": "Aufgabe löschen", "deleteTaskMessage": "Bist du sicher, dass du diese Aufgabe löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", "cancel": "Abbrechen", diff --git a/webview-ui/src/i18n/locales/en/history.json b/webview-ui/src/i18n/locales/en/history.json index 6d53cd3663..0eb7ea53ec 100644 --- a/webview-ui/src/i18n/locales/en/history.json +++ b/webview-ui/src/i18n/locales/en/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Delete Task (Shift + Click to skip confirmation)", "copyPrompt": "Copy Prompt", "exportTask": "Export Task", + "renameTask": "Rename Task", "deleteTask": "Delete Task", "deleteTaskMessage": "Are you sure you want to delete this task? This action cannot be undone.", "cancel": "Cancel", diff --git a/webview-ui/src/i18n/locales/es/history.json b/webview-ui/src/i18n/locales/es/history.json index 91d28abb99..a555ca5e73 100644 --- a/webview-ui/src/i18n/locales/es/history.json +++ b/webview-ui/src/i18n/locales/es/history.json @@ -20,6 +20,7 @@ "apiCostLabel": "Costo de API:", "copyPrompt": "Copiar prompt", "exportTask": "Exportar tarea", + "renameTask": "Renombrar tarea", "deleteTask": "Eliminar tarea", "deleteTaskMessage": "¿Estás seguro de que quieres eliminar esta tarea? Esta acción no se puede deshacer.", "cancel": "Cancelar", diff --git a/webview-ui/src/i18n/locales/fr/history.json b/webview-ui/src/i18n/locales/fr/history.json index 443bb0eb3e..a240602dba 100644 --- a/webview-ui/src/i18n/locales/fr/history.json +++ b/webview-ui/src/i18n/locales/fr/history.json @@ -20,6 +20,7 @@ "apiCostLabel": "Coût API:", "copyPrompt": "Copier le prompt", "exportTask": "Exporter la tâche", + "renameTask": "Renommer la tâche", "deleteTask": "Supprimer la tâche", "deleteTaskMessage": "Êtes-vous sûr de vouloir supprimer cette tâche ? Cette action ne peut pas être annulée.", "cancel": "Annuler", diff --git a/webview-ui/src/i18n/locales/hi/history.json b/webview-ui/src/i18n/locales/hi/history.json index 3dd7cca9a9..c3a6dc7526 100644 --- a/webview-ui/src/i18n/locales/hi/history.json +++ b/webview-ui/src/i18n/locales/hi/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "कार्य हटाएं (Shift + क्लिक पुष्टि छोड़ने के लिए)", "copyPrompt": "प्रॉम्प्ट कॉपी करें", "exportTask": "कार्य निर्यात करें", + "renameTask": "कार्य का नाम बदलें", "deleteTask": "कार्य हटाएं", "deleteTaskMessage": "क्या आप वाकई इस कार्य को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती है।", "cancel": "रद्द करें", diff --git a/webview-ui/src/i18n/locales/id/history.json b/webview-ui/src/i18n/locales/id/history.json index 772ca25384..4cc3503970 100644 --- a/webview-ui/src/i18n/locales/id/history.json +++ b/webview-ui/src/i18n/locales/id/history.json @@ -20,6 +20,7 @@ "apiCostLabel": "Biaya API:", "copyPrompt": "Salin Prompt", "exportTask": "Ekspor Tugas", + "renameTask": "Ganti Nama Tugas", "deleteTask": "Hapus Tugas", "deleteTaskMessage": "Apakah kamu yakin ingin menghapus tugas ini? Aksi ini tidak dapat dibatalkan.", "cancel": "Batal", diff --git a/webview-ui/src/i18n/locales/it/history.json b/webview-ui/src/i18n/locales/it/history.json index 4097d43ce2..5719dc3cb9 100644 --- a/webview-ui/src/i18n/locales/it/history.json +++ b/webview-ui/src/i18n/locales/it/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Elimina attività (Shift + Clic per saltare conferma)", "copyPrompt": "Copia prompt", "exportTask": "Esporta attività", + "renameTask": "Rinomina attività", "deleteTask": "Elimina attività", "deleteTaskMessage": "Sei sicuro di voler eliminare questa attività? Questa azione non può essere annullata.", "cancel": "Annulla", diff --git a/webview-ui/src/i18n/locales/ja/history.json b/webview-ui/src/i18n/locales/ja/history.json index be4897ba34..1f635dfb54 100644 --- a/webview-ui/src/i18n/locales/ja/history.json +++ b/webview-ui/src/i18n/locales/ja/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "タスクを削除(Shift + クリックで確認をスキップ)", "copyPrompt": "プロンプトをコピー", "exportTask": "タスクをエクスポート", + "renameTask": "タスク名を変更", "deleteTask": "タスクを削除", "deleteTaskMessage": "このタスクを削除してもよろしいですか?この操作は元に戻せません。", "cancel": "キャンセル", diff --git a/webview-ui/src/i18n/locales/ko/history.json b/webview-ui/src/i18n/locales/ko/history.json index 8a13ff12cd..b5a204b7bf 100644 --- a/webview-ui/src/i18n/locales/ko/history.json +++ b/webview-ui/src/i18n/locales/ko/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "작업 삭제 (Shift + 클릭으로 확인 생략)", "copyPrompt": "프롬프트 복사", "exportTask": "작업 내보내기", + "renameTask": "작업 이름 바꾸기", "deleteTask": "작업 삭제", "deleteTaskMessage": "이 작업을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "cancel": "취소", diff --git a/webview-ui/src/i18n/locales/nl/history.json b/webview-ui/src/i18n/locales/nl/history.json index db1515bfe5..2a72b0bfe8 100644 --- a/webview-ui/src/i18n/locales/nl/history.json +++ b/webview-ui/src/i18n/locales/nl/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Taak verwijderen (Shift + Klik om bevestiging over te slaan)", "copyPrompt": "Prompt kopiëren", "exportTask": "Taak exporteren", + "renameTask": "Taak hernoemen", "deleteTask": "Taak verwijderen", "deleteTaskMessage": "Weet je zeker dat je deze taak wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", "cancel": "Annuleren", diff --git a/webview-ui/src/i18n/locales/pl/history.json b/webview-ui/src/i18n/locales/pl/history.json index 2924d4710e..229f28030f 100644 --- a/webview-ui/src/i18n/locales/pl/history.json +++ b/webview-ui/src/i18n/locales/pl/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Usuń zadanie (Shift + Klik, aby pominąć potwierdzenie)", "copyPrompt": "Kopiuj prompt", "exportTask": "Eksportuj zadanie", + "renameTask": "Zmień nazwę zadania", "deleteTask": "Usuń zadanie", "deleteTaskMessage": "Czy na pewno chcesz usunąć to zadanie? Tej akcji nie można cofnąć.", "cancel": "Anuluj", diff --git a/webview-ui/src/i18n/locales/pt-BR/history.json b/webview-ui/src/i18n/locales/pt-BR/history.json index 79c84b70ef..c9b2848ee6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/history.json +++ b/webview-ui/src/i18n/locales/pt-BR/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Excluir tarefa (Shift + Clique para pular confirmação)", "copyPrompt": "Copiar prompt", "exportTask": "Exportar tarefa", + "renameTask": "Renomear tarefa", "deleteTask": "Excluir tarefa", "deleteTaskMessage": "Tem certeza que deseja excluir esta tarefa? Esta ação não pode ser desfeita.", "cancel": "Cancelar", diff --git a/webview-ui/src/i18n/locales/ru/history.json b/webview-ui/src/i18n/locales/ru/history.json index 3035ec59d9..482b8453ad 100644 --- a/webview-ui/src/i18n/locales/ru/history.json +++ b/webview-ui/src/i18n/locales/ru/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Удалить задачу (Shift + клик для пропуска подтверждения)", "copyPrompt": "Скопировать запрос", "exportTask": "Экспортировать задачу", + "renameTask": "Переименовать задачу", "deleteTask": "Удалить задачу", "deleteTaskMessage": "Вы уверены, что хотите удалить эту задачу? Это действие нельзя отменить.", "cancel": "Отмена", diff --git a/webview-ui/src/i18n/locales/tr/history.json b/webview-ui/src/i18n/locales/tr/history.json index 2ebc1a0154..10b7e82362 100644 --- a/webview-ui/src/i18n/locales/tr/history.json +++ b/webview-ui/src/i18n/locales/tr/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Görevi Sil (Onayı atlamak için Shift + Tıkla)", "copyPrompt": "Promptu Kopyala", "exportTask": "Görevi Dışa Aktar", + "renameTask": "Görevi Yeniden Adlandır", "deleteTask": "Görevi Sil", "deleteTaskMessage": "Bu görevi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "cancel": "İptal", diff --git a/webview-ui/src/i18n/locales/vi/history.json b/webview-ui/src/i18n/locales/vi/history.json index a6efa0671e..48ec3a00a4 100644 --- a/webview-ui/src/i18n/locales/vi/history.json +++ b/webview-ui/src/i18n/locales/vi/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "Xóa nhiệm vụ (Shift + Click để bỏ qua xác nhận)", "copyPrompt": "Sao chép lời nhắc", "exportTask": "Xuất nhiệm vụ", + "renameTask": "Đổi tên nhiệm vụ", "deleteTask": "Xóa nhiệm vụ", "deleteTaskMessage": "Bạn có chắc chắn muốn xóa nhiệm vụ này không? Hành động này không thể hoàn tác.", "cancel": "Hủy", diff --git a/webview-ui/src/i18n/locales/zh-CN/history.json b/webview-ui/src/i18n/locales/zh-CN/history.json index 6b6bd03300..2c97a23067 100644 --- a/webview-ui/src/i18n/locales/zh-CN/history.json +++ b/webview-ui/src/i18n/locales/zh-CN/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "删除任务(Shift + 点击跳过确认)", "copyPrompt": "复制提示词", "exportTask": "导出任务", + "renameTask": "重命名任务", "deleteTask": "删除任务", "deleteTaskMessage": "确认删除该任务?此操作不可逆。", "cancel": "取消", diff --git a/webview-ui/src/i18n/locales/zh-TW/history.json b/webview-ui/src/i18n/locales/zh-TW/history.json index 5fb3230c80..01bb30014b 100644 --- a/webview-ui/src/i18n/locales/zh-TW/history.json +++ b/webview-ui/src/i18n/locales/zh-TW/history.json @@ -13,6 +13,7 @@ "deleteTaskTitle": "刪除工作(按住 Shift 並點選可跳過確認)", "copyPrompt": "複製提示詞", "exportTask": "匯出工作", + "renameTask": "重新命名工作", "deleteTask": "刪除工作", "deleteTaskMessage": "確定要刪除此工作嗎?此操作無法復原。", "cancel": "取消",