Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/types/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ export interface WebviewMessage {
| "deleteTaskWithId"
| "abandonSubtaskWithId"
| "exportTaskWithId"
| "renameTaskWithId"
| "importSettings"
| "exportSettings"
| "resetState"
Expand Down
39 changes: 37 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2450,6 +2450,33 @@
}
}

/*
* 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) => ({
Comment thread
designPrj marked this conversation as resolved.
...historyItem,
title: trimmed ? trimmed : undefined,
}))
this.recentTasksCache = undefined

if (this.isViewLaunched) {

Check warning on line 2472 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2472: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
const updatedItem = this.taskHistoryStore.get(id)
if (updatedItem) {

Check warning on line 2474 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2474: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem })
}
}
}
Comment thread
designPrj marked this conversation as resolved.

/* Condenses a task's message history to use fewer tokens. */
async condenseTaskContext(taskId: string) {
const task = this.taskRegistry.getById(taskId)
Expand Down Expand Up @@ -3247,20 +3274,28 @@
* 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
*/
async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise<HistoryItem[]> {
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

Check warning on line 3298 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:3298: Survived LogicalOperator mutant (replacement: this.taskHistoryStore.get(item.id) && itemWithoutTitle). See the job summary for the complete list and resolution guidance.
await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem })
}

Expand Down
94 changes: 94 additions & 0 deletions src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
})
5 changes: 5 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
designPrj marked this conversation as resolved.
case "getTaskWithAggregatedCosts": {
try {
const taskId = message.text
Expand Down
37 changes: 37 additions & 0 deletions webview-ui/playwright/gallery/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,43 @@ export const stories: Record<string, Story> = {
</AppProviders>
)
},
"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 (
<AppProviders
initialState={{
cwd: workspace,
taskHistory: [
{
...baseItem,
id: "task-renamed",
number: 2,
ts: now - 2 * 60 * 60 * 1000,
task: "Refactor the authentication middleware to use the new session store",
title: "Auth refactor",
},
{
...baseItem,
id: "task-original",
number: 1,
ts: now - 26 * 60 * 60 * 1000,
task: "Write unit tests for the billing service",
},
],
}}>
<div className="h-[640px] w-[480px] max-w-full bg-vscode-editor-background">
<HistoryView onDone={() => undefined} />
</div>
</AppProviders>
)
},
"ignored-disabled-tools-warning": async () => {
const [{ AppProviders }, { ChatRowContent }] = await Promise.all([
import("../AppProviders"),
Expand Down
21 changes: 19 additions & 2 deletions webview-ui/src/components/chat/TaskActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,39 @@ 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<string | null>(null)
const { t } = useTranslation()
const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard()
const { debug } = useExtensionState()

return (
<div className="flex flex-row items-center -ml-0.5 mt-1 gap-1">
{item?.id && onRename && (
<LucideIconButton
icon={PencilIcon}
title={t("history:renameTask")}
data-testid="rename-current-task-button"
onClick={onRename}
/>
)}
<LucideIconButton
icon={DownloadIcon}
title={t("chat:task.export")}
Expand Down
26 changes: 23 additions & 3 deletions webview-ui/src/components/chat/TaskHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { vscode } from "@src/utils/vscode"

import Thumbnails from "../common/Thumbnails"
import { TaskTitleEditor } from "../history/TaskTitleEditor"
import { TaskActions } from "./TaskActions"
import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
Expand Down Expand Up @@ -66,6 +67,8 @@ const TaskHeader = ({
const { apiConfiguration, currentTaskItem } = useExtensionState()
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [isRenaming, setIsRenaming] = useState(false)
const customTitle = currentTaskItem?.title

const textContainerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -179,10 +182,23 @@ const TaskHeader = ({
<div className="flex justify-between items-center gap-0">
<div className="flex items-center select-none grow min-w-0">
<div className="grow min-w-0">
{isTaskExpanded && <span className="font-bold">{t("chat:task.title")}</span>}
{isTaskExpanded &&
(isRenaming && currentTaskItem ? (
<TaskTitleEditor
item={currentTaskItem}
onDone={() => setIsRenaming(false)}
className="text-sm"
/>
) : (
<span className="font-bold">{customTitle || t("chat:task.title")}</span>
))}
{!isTaskExpanded && (
<div className="flex items-center gap-2 whitespace-nowrap overflow-hidden text-ellipsis">
<Mention text={task.text} />
{customTitle ? (
<span className="overflow-hidden text-ellipsis">{customTitle}</span>
) : (
<Mention text={task.text} />
)}
</div>
)}
</div>
Expand Down Expand Up @@ -336,7 +352,11 @@ const TaskHeader = ({
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}

<div onClick={(e) => e.stopPropagation()}>
<TaskActions item={currentTaskItem} buttonsDisabled={buttonsDisabled} />
<TaskActions
item={currentTaskItem}
buttonsDisabled={buttonsDisabled}
onRename={isRenaming ? undefined : () => setIsRenaming(true)}
/>
</div>

<div className="pt-3 mt-2 -mx-2.5 px-2.5 border-t border-vscode-sideBar-background">
Expand Down
Loading
Loading