Skip to content

Commit 0b7a9a4

Browse files
committed
feat: add star functionality to tasks (#6244)
- Add isStarred field to HistoryItem type - Add starredTaskIds to GlobalState for persistent storage - Implement toggleTaskStar message handler in webviewMessageHandler - Update ClineProvider to map starred status to tasks - Add star button to TaskItemHeader component - Sort starred tasks to the top in useTaskSearch hook - Prevent deletion of starred tasks with warning messages - Add comprehensive tests for star functionality Fixes #6244
1 parent 6216075 commit 0b7a9a4

File tree

13 files changed

+246
-275
lines changed

13 files changed

+246
-275
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const globalSettingsSchema = z.object({
4141
lastShownAnnouncementId: z.string().optional(),
4242
customInstructions: z.string().optional(),
4343
taskHistory: z.array(historyItemSchema).optional(),
44+
starredTaskIds: z.array(z.string()).optional(),
4445

4546
condensingApiConfigId: z.string().optional(),
4647
customCondensingPrompt: z.string().optional(),

packages/types/src/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
1616
totalCost: z.number(),
1717
size: z.number().optional(),
1818
workspace: z.string().optional(),
19+
isStarred: z.boolean().optional(),
1920
})
2021

2122
export type HistoryItem = z.infer<typeof historyItemSchema>

src/core/webview/ClineProvider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1478,7 +1478,11 @@ export class ClineProvider
14781478
clineMessages: this.getCurrentCline()?.clineMessages || [],
14791479
taskHistory: (taskHistory || [])
14801480
.filter((item: HistoryItem) => item.ts && item.task)
1481-
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
1481+
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts)
1482+
.map((item: HistoryItem) => ({
1483+
...item,
1484+
isStarred: (this.getGlobalState("starredTaskIds") || []).includes(item.id),
1485+
})),
14821486
soundEnabled: soundEnabled ?? false,
14831487
ttsEnabled: ttsEnabled ?? false,
14841488
ttsSpeed: ttsSpeed ?? 1.0,

src/core/webview/webviewMessageHandler.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,6 +2345,38 @@ export const webviewMessageHandler = async (
23452345
break
23462346
}
23472347

2348+
case "toggleTaskStar": {
2349+
if (message.text) {
2350+
const taskId = message.text
2351+
const starredTaskIds = getGlobalState("starredTaskIds") || []
2352+
const isCurrentlyStarred = starredTaskIds.includes(taskId)
2353+
2354+
let updatedStarredTaskIds: string[]
2355+
if (isCurrentlyStarred) {
2356+
// Unstar the task
2357+
updatedStarredTaskIds = starredTaskIds.filter((id) => id !== taskId)
2358+
} else {
2359+
// Star the task
2360+
updatedStarredTaskIds = [...starredTaskIds, taskId]
2361+
}
2362+
2363+
await updateGlobalState("starredTaskIds", updatedStarredTaskIds)
2364+
2365+
// Update the task history to reflect the starred status
2366+
const taskHistory = getGlobalState("taskHistory") || []
2367+
const updatedTaskHistory = taskHistory.map((task) => {
2368+
if (task.id === taskId) {
2369+
return { ...task, isStarred: !isCurrentlyStarred }
2370+
}
2371+
return task
2372+
})
2373+
await updateGlobalState("taskHistory", updatedTaskHistory)
2374+
2375+
await provider.postStateToWebview()
2376+
}
2377+
break
2378+
}
2379+
23482380
case "switchTab": {
23492381
if (message.tab) {
23502382
// Capture tab shown event for all switchTab messages (which are user-initiated)

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface WebviewMessage {
5858
| "showTaskWithId"
5959
| "deleteTaskWithId"
6060
| "exportTaskWithId"
61+
| "toggleTaskStar"
6162
| "importSettings"
6263
| "exportSettings"
6364
| "resetState"

webview-ui/src/components/history/BatchDeleteTaskDialog.tsx

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from "react"
1+
import { useCallback, useMemo } from "react"
22
import { useAppTranslation } from "@/i18n/TranslationContext"
33
import {
44
AlertDialog,
@@ -13,6 +13,7 @@ import {
1313
} from "@/components/ui"
1414
import { vscode } from "@/utils/vscode"
1515
import { AlertDialogProps } from "@radix-ui/react-alert-dialog"
16+
import { useExtensionState } from "@/context/ExtensionStateContext"
1617

1718
interface BatchDeleteTaskDialogProps extends AlertDialogProps {
1819
taskIds: string[]
@@ -21,36 +22,72 @@ interface BatchDeleteTaskDialogProps extends AlertDialogProps {
2122
export const BatchDeleteTaskDialog = ({ taskIds, ...props }: BatchDeleteTaskDialogProps) => {
2223
const { t } = useAppTranslation()
2324
const { onOpenChange } = props
25+
const { taskHistory } = useExtensionState()
26+
27+
// Check if any of the selected tasks are starred
28+
const starredTaskIds = useMemo(() => {
29+
return taskIds.filter((id) => {
30+
const task = taskHistory.find((t) => t.id === id)
31+
return task?.isStarred || false
32+
})
33+
}, [taskIds, taskHistory])
34+
35+
const hasStarredTasks = starredTaskIds.length > 0
36+
const unstarredTaskIds = taskIds.filter((id) => !starredTaskIds.includes(id))
2437

2538
const onDelete = useCallback(() => {
26-
if (taskIds.length > 0) {
27-
vscode.postMessage({ type: "deleteMultipleTasksWithIds", ids: taskIds })
39+
if (unstarredTaskIds.length > 0) {
40+
vscode.postMessage({ type: "deleteMultipleTasksWithIds", ids: unstarredTaskIds })
2841
onOpenChange?.(false)
2942
}
30-
}, [taskIds, onOpenChange])
43+
}, [unstarredTaskIds, onOpenChange])
3144

3245
return (
3346
<AlertDialog {...props}>
3447
<AlertDialogContent className="max-w-md">
3548
<AlertDialogHeader>
3649
<AlertDialogTitle>{t("history:deleteTasks")}</AlertDialogTitle>
3750
<AlertDialogDescription className="text-vscode-foreground">
38-
<div className="mb-2">{t("history:confirmDeleteTasks", { count: taskIds.length })}</div>
39-
<div className="text-vscode-editor-foreground bg-vscode-editor-background p-2 rounded text-sm">
40-
{t("history:deleteTasksWarning")}
41-
</div>
51+
{hasStarredTasks ? (
52+
<>
53+
<div className="mb-2 text-vscode-errorForeground">
54+
{starredTaskIds.length === taskIds.length
55+
? "All selected tasks are starred. Please unstar them before deleting."
56+
: `${starredTaskIds.length} of ${taskIds.length} selected tasks are starred and will not be deleted.`}
57+
</div>
58+
{unstarredTaskIds.length > 0 && (
59+
<>
60+
<div className="mb-2">
61+
{t("history:confirmDeleteTasks", { count: unstarredTaskIds.length })}
62+
</div>
63+
<div className="text-vscode-editor-foreground bg-vscode-editor-background p-2 rounded text-sm">
64+
{t("history:deleteTasksWarning")}
65+
</div>
66+
</>
67+
)}
68+
</>
69+
) : (
70+
<>
71+
<div className="mb-2">{t("history:confirmDeleteTasks", { count: taskIds.length })}</div>
72+
<div className="text-vscode-editor-foreground bg-vscode-editor-background p-2 rounded text-sm">
73+
{t("history:deleteTasksWarning")}
74+
</div>
75+
</>
76+
)}
4277
</AlertDialogDescription>
4378
</AlertDialogHeader>
4479
<AlertDialogFooter>
4580
<AlertDialogCancel asChild>
4681
<Button variant="secondary">{t("history:cancel")}</Button>
4782
</AlertDialogCancel>
48-
<AlertDialogAction asChild>
49-
<Button variant="destructive" onClick={onDelete}>
50-
<span className="codicon codicon-trash mr-1"></span>
51-
{t("history:deleteItems", { count: taskIds.length })}
52-
</Button>
53-
</AlertDialogAction>
83+
{unstarredTaskIds.length > 0 && (
84+
<AlertDialogAction asChild>
85+
<Button variant="destructive" onClick={onDelete}>
86+
<span className="codicon codicon-trash mr-1"></span>
87+
{t("history:deleteItems", { count: unstarredTaskIds.length })}
88+
</Button>
89+
</AlertDialogAction>
90+
)}
5491
</AlertDialogFooter>
5592
</AlertDialogContent>
5693
</AlertDialog>

webview-ui/src/components/history/DeleteTaskDialog.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Button,
1515
} from "@/components/ui"
1616
import { useAppTranslation } from "@/i18n/TranslationContext"
17+
import { useExtensionState } from "@/context/ExtensionStateContext"
1718

1819
import { vscode } from "@/utils/vscode"
1920

@@ -24,38 +25,49 @@ interface DeleteTaskDialogProps extends AlertDialogProps {
2425
export const DeleteTaskDialog = ({ taskId, ...props }: DeleteTaskDialogProps) => {
2526
const { t } = useAppTranslation()
2627
const [isEnterPressed] = useKeyPress("Enter")
28+
const { taskHistory } = useExtensionState()
2729

2830
const { onOpenChange } = props
2931

32+
// Check if the task is starred
33+
const task = taskHistory.find((t) => t.id === taskId)
34+
const isStarred = task?.isStarred || false
35+
3036
const onDelete = useCallback(() => {
31-
if (taskId) {
37+
if (taskId && !isStarred) {
3238
vscode.postMessage({ type: "deleteTaskWithId", text: taskId })
3339
onOpenChange?.(false)
3440
}
35-
}, [taskId, onOpenChange])
41+
}, [taskId, isStarred, onOpenChange])
3642

3743
useEffect(() => {
38-
if (taskId && isEnterPressed) {
44+
if (taskId && isEnterPressed && !isStarred) {
3945
onDelete()
4046
}
41-
}, [taskId, isEnterPressed, onDelete])
47+
}, [taskId, isEnterPressed, isStarred, onDelete])
4248

4349
return (
4450
<AlertDialog {...props}>
4551
<AlertDialogContent onEscapeKeyDown={() => onOpenChange?.(false)}>
4652
<AlertDialogHeader>
4753
<AlertDialogTitle>{t("history:deleteTask")}</AlertDialogTitle>
48-
<AlertDialogDescription>{t("history:deleteTaskMessage")}</AlertDialogDescription>
54+
<AlertDialogDescription>
55+
{isStarred
56+
? "This task is starred. Please unstar it before deleting."
57+
: t("history:deleteTaskMessage")}
58+
</AlertDialogDescription>
4959
</AlertDialogHeader>
5060
<AlertDialogFooter>
5161
<AlertDialogCancel asChild>
5262
<Button variant="secondary">{t("history:cancel")}</Button>
5363
</AlertDialogCancel>
54-
<AlertDialogAction asChild>
55-
<Button variant="destructive" onClick={onDelete}>
56-
{t("history:delete")}
57-
</Button>
58-
</AlertDialogAction>
64+
{!isStarred && (
65+
<AlertDialogAction asChild>
66+
<Button variant="destructive" onClick={onDelete}>
67+
{t("history:delete")}
68+
</Button>
69+
</AlertDialogAction>
70+
)}
5971
</AlertDialogFooter>
6072
</AlertDialogContent>
6173
</AlertDialog>

webview-ui/src/components/history/TaskItemHeader.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { HistoryItem } from "@roo-code/types"
33
import { formatDate } from "@/utils/format"
44
import { DeleteButton } from "./DeleteButton"
55
import { cn } from "@/lib/utils"
6+
import { vscode } from "@/utils/vscode"
67

78
export interface TaskItemHeaderProps {
89
item: HistoryItem
@@ -11,22 +12,38 @@ export interface TaskItemHeaderProps {
1112
}
1213

1314
const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, isSelectionMode, onDelete }) => {
15+
const handleStarClick = (e: React.MouseEvent) => {
16+
e.stopPropagation()
17+
vscode.postMessage({ type: "toggleTaskStar", text: item.id })
18+
}
19+
1420
return (
1521
<div
1622
className={cn("flex justify-between items-center", {
1723
// this is to balance out the margin when we don't have a delete button
1824
// because the delete button sorta pushes the date up due to its size
19-
"mb-1": !onDelete,
25+
"mb-1": !onDelete && !item.isStarred,
2026
})}>
2127
<div className="flex items-center flex-wrap gap-x-2 text-xs">
2228
<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
2329
{formatDate(item.ts)}
2430
</span>
31+
{item.isStarred && (
32+
<span className="text-vscode-textPreformat-foreground" title="Starred task">
33+
<i className="codicon codicon-star-full" />
34+
</span>
35+
)}
2536
</div>
2637

2738
{/* Action Buttons */}
2839
{!isSelectionMode && (
2940
<div className="flex flex-row gap-0 items-center opacity-20 group-hover:opacity-50 hover:opacity-100">
41+
<button
42+
onClick={handleStarClick}
43+
className="p-1 hover:bg-vscode-toolbar-hoverBackground rounded"
44+
title={item.isStarred ? "Unstar task" : "Star task"}>
45+
<i className={cn("codicon", item.isStarred ? "codicon-star-full" : "codicon-star-empty")} />
46+
</button>
3047
{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
3148
</div>
3249
)}

webview-ui/src/components/history/__tests__/BatchDeleteTaskDialog.spec.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ vi.mock("@/i18n/TranslationContext", () => ({
2121
}),
2222
}))
2323

24+
vi.mock("@/context/ExtensionStateContext", () => ({
25+
useExtensionState: () => ({
26+
taskHistory: [
27+
{ id: "task-1", isStarred: false },
28+
{ id: "task-2", isStarred: false },
29+
{ id: "task-3", isStarred: false },
30+
],
31+
}),
32+
}))
33+
2434
describe("BatchDeleteTaskDialog", () => {
2535
const mockTaskIds = ["task-1", "task-2", "task-3"]
2636
const mockOnOpenChange = vi.fn()
@@ -65,8 +75,13 @@ describe("BatchDeleteTaskDialog", () => {
6575
it("does not call vscode.postMessage when taskIds is empty", () => {
6676
render(<BatchDeleteTaskDialog taskIds={[]} open={true} onOpenChange={mockOnOpenChange} />)
6777

68-
const deleteButton = screen.getByText("Delete 0 items")
69-
fireEvent.click(deleteButton)
78+
// When there are no tasks, there should be no delete button
79+
const deleteButton = screen.queryByText("Delete 0 items")
80+
expect(deleteButton).not.toBeInTheDocument()
81+
82+
// Cancel button should still work
83+
const cancelButton = screen.getByText("Cancel")
84+
fireEvent.click(cancelButton)
7085

7186
expect(vscode.postMessage).not.toHaveBeenCalled()
7287
expect(mockOnOpenChange).toHaveBeenCalledWith(false)

webview-ui/src/components/history/__tests__/DeleteTaskDialog.spec.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ vi.mock("react-use", () => ({
2424
useKeyPress: vi.fn(),
2525
}))
2626

27+
vi.mock("@/context/ExtensionStateContext", () => ({
28+
useExtensionState: () => ({
29+
taskHistory: [
30+
{ id: "test-task-id", isStarred: false },
31+
{ id: "starred-task-id", isStarred: true },
32+
],
33+
}),
34+
}))
35+
2736
import { useKeyPress } from "react-use"
2837

2938
const mockUseKeyPress = useKeyPress as any

0 commit comments

Comments
 (0)