Skip to content

Commit da79983

Browse files
committed
feat: Add editable titles to tasks and task history
- Add optional title field to HistoryItem type schema - Add updateTaskTitle message types to Extension and Webview messages - Implement title update handler in webviewMessageHandler with trimming - Add title editing UI to TaskHeader component with inline edit mode - Update TaskItem component to display titles prominently in history - Add translations for title editing UI elements - Add tests for backend title update functionality Implements #8366
1 parent ded23b7 commit da79983

File tree

10 files changed

+553
-7
lines changed

10 files changed

+553
-7
lines changed

packages/types/src/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const historyItemSchema = z.object({
1111
number: z.number(),
1212
ts: z.number(),
1313
task: z.string(),
14+
title: z.string().optional(), // User-defined title for the task
1415
tokensIn: z.number(),
1516
tokensOut: z.number(),
1617
cacheWrites: z.number().optional(),

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const mockClineProvider = {
3737
getCurrentTask: vi.fn(),
3838
getTaskWithId: vi.fn(),
3939
createTaskWithHistoryItem: vi.fn(),
40+
updateTaskHistory: vi.fn(),
4041
} as unknown as ClineProvider
4142

4243
import { t } from "../../../i18n"
@@ -708,3 +709,188 @@ describe("webviewMessageHandler - mcpEnabled", () => {
708709
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
709710
})
710711
})
712+
713+
describe("webviewMessageHandler - updateTaskTitle", () => {
714+
beforeEach(() => {
715+
vi.clearAllMocks()
716+
// Mock getGlobalState to return a task history
717+
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue([
718+
{
719+
id: "task-1",
720+
ts: 123456789,
721+
task: "Original task text",
722+
title: "Original title",
723+
},
724+
{
725+
id: "task-2",
726+
ts: 987654321,
727+
task: "Another task",
728+
// No title
729+
},
730+
])
731+
})
732+
733+
it("should update task title when task exists", async () => {
734+
// Mock updateTaskHistory to succeed
735+
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
736+
737+
await webviewMessageHandler(mockClineProvider, {
738+
type: "updateTaskTitle",
739+
taskId: "task-1",
740+
title: "New updated title",
741+
})
742+
743+
// Verify updateTaskHistory was called with the updated task
744+
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
745+
id: "task-1",
746+
ts: 123456789,
747+
task: "Original task text",
748+
title: "New updated title",
749+
})
750+
751+
// Verify state was posted to webview
752+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
753+
})
754+
755+
it("should clear task title when empty string is provided", async () => {
756+
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
757+
758+
await webviewMessageHandler(mockClineProvider, {
759+
type: "updateTaskTitle",
760+
taskId: "task-1",
761+
title: "",
762+
})
763+
764+
// Verify updateTaskHistory was called with undefined title
765+
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
766+
id: "task-1",
767+
ts: 123456789,
768+
task: "Original task text",
769+
title: undefined,
770+
})
771+
772+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
773+
})
774+
775+
it("should add title to task that didn't have one", async () => {
776+
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
777+
778+
await webviewMessageHandler(mockClineProvider, {
779+
type: "updateTaskTitle",
780+
taskId: "task-2",
781+
title: "Brand new title",
782+
})
783+
784+
// Verify updateTaskHistory was called with the new title
785+
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
786+
id: "task-2",
787+
ts: 987654321,
788+
task: "Another task",
789+
title: "Brand new title",
790+
})
791+
792+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
793+
})
794+
795+
it("should not update when task is not found", async () => {
796+
await webviewMessageHandler(mockClineProvider, {
797+
type: "updateTaskTitle",
798+
taskId: "non-existent-task",
799+
title: "Some title",
800+
})
801+
802+
// Verify updateTaskHistory was NOT called
803+
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
804+
805+
// State should not be posted either
806+
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
807+
})
808+
809+
it("should not update when taskId is missing", async () => {
810+
await webviewMessageHandler(mockClineProvider, {
811+
type: "updateTaskTitle",
812+
// No taskId provided
813+
title: "Some title",
814+
})
815+
816+
// Verify updateTaskHistory was NOT called
817+
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
818+
819+
// State should not be posted either
820+
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
821+
})
822+
823+
it("should handle empty task history gracefully", async () => {
824+
// Mock empty task history
825+
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined)
826+
827+
await webviewMessageHandler(mockClineProvider, {
828+
type: "updateTaskTitle",
829+
taskId: "task-1",
830+
title: "New title",
831+
})
832+
833+
// Verify updateTaskHistory was NOT called
834+
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
835+
836+
// State should not be posted either
837+
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
838+
})
839+
840+
it("should handle null task history gracefully", async () => {
841+
// Mock null task history
842+
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(null)
843+
844+
await webviewMessageHandler(mockClineProvider, {
845+
type: "updateTaskTitle",
846+
taskId: "task-1",
847+
title: "New title",
848+
})
849+
850+
// Verify updateTaskHistory was NOT called
851+
expect(mockClineProvider.updateTaskHistory).not.toHaveBeenCalled()
852+
853+
// State should not be posted either
854+
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
855+
})
856+
857+
it("should trim whitespace from title", async () => {
858+
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
859+
860+
await webviewMessageHandler(mockClineProvider, {
861+
type: "updateTaskTitle",
862+
taskId: "task-1",
863+
title: " Trimmed Title ",
864+
})
865+
866+
// Verify updateTaskHistory was called with trimmed title
867+
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
868+
id: "task-1",
869+
ts: 123456789,
870+
task: "Original task text",
871+
title: "Trimmed Title",
872+
})
873+
874+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
875+
})
876+
877+
it("should handle whitespace-only title as empty", async () => {
878+
vi.mocked(mockClineProvider.updateTaskHistory).mockResolvedValue([])
879+
880+
await webviewMessageHandler(mockClineProvider, {
881+
type: "updateTaskTitle",
882+
taskId: "task-1",
883+
title: " ",
884+
})
885+
886+
// Verify updateTaskHistory was called with undefined (cleared title)
887+
expect(mockClineProvider.updateTaskHistory).toHaveBeenCalledWith({
888+
id: "task-1",
889+
ts: 123456789,
890+
task: "Original task text",
891+
title: undefined,
892+
})
893+
894+
expect(mockClineProvider.postStateToWebview).toHaveBeenCalled()
895+
})
896+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3110,5 +3110,34 @@ export const webviewMessageHandler = async (
31103110
})
31113111
break
31123112
}
3113+
case "updateTaskTitle": {
3114+
// Handle task title update
3115+
if (message.taskId && message.title !== undefined) {
3116+
try {
3117+
// Get the current task history
3118+
const history = getGlobalState("taskHistory") ?? []
3119+
const taskItem = history.find((item) => item.id === message.taskId)
3120+
3121+
if (taskItem) {
3122+
// Update the title field - trim whitespace and set to undefined if empty
3123+
const trimmedTitle = message.title?.trim()
3124+
taskItem.title = trimmedTitle || undefined // Set to undefined if empty string
3125+
3126+
// Save the updated task item
3127+
await provider.updateTaskHistory(taskItem)
3128+
3129+
// Post updated state back to webview
3130+
await provider.postStateToWebview()
3131+
} else {
3132+
provider.log(`Task not found for title update: ${message.taskId}`)
3133+
vscode.window.showErrorMessage(t("common:errors.task_not_found"))
3134+
}
3135+
} catch (error) {
3136+
provider.log(`Error updating task title: ${error instanceof Error ? error.message : String(error)}`)
3137+
vscode.window.showErrorMessage(t("common:errors.update_task_title"))
3138+
}
3139+
}
3140+
break
3141+
}
31133142
}
31143143
}

src/i18n/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@
9797
"error_deleting_message": "Error deleting message: {{error}}",
9898
"error_editing_message": "Error editing message: {{error}}"
9999
},
100+
"task_not_found": "Task not found",
101+
"update_task_title": "Failed to update task title",
100102
"gemini": {
101103
"generate_stream": "Gemini generate context stream error: {{error}}",
102104
"generate_complete_prompt": "Gemini completion error: {{error}}",

src/shared/ExtensionMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,11 @@ export interface ExtensionMessage {
126126
| "insertTextIntoTextarea"
127127
| "dismissedUpsells"
128128
| "organizationSwitchResult"
129+
| "taskTitleUpdated"
129130
text?: string
130131
payload?: any // Add a generic payload for now, can refine later
132+
taskId?: string // For task title updates
133+
title?: string // For task title updates
131134
action?:
132135
| "chatButtonClicked"
133136
| "mcpButtonClicked"

src/shared/WebviewMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface WebviewMessage {
6161
| "showTaskWithId"
6262
| "deleteTaskWithId"
6363
| "exportTaskWithId"
64+
| "updateTaskTitle"
6465
| "importSettings"
6566
| "exportSettings"
6667
| "resetState"
@@ -235,6 +236,8 @@ export interface WebviewMessage {
235236
disabled?: boolean
236237
context?: string
237238
dataUri?: string
239+
taskId?: string // For task title updates
240+
title?: string // For task title updates
238241
askResponse?: ClineAskResponse
239242
apiConfiguration?: ProviderSettings
240243
images?: string[]

0 commit comments

Comments
 (0)