feat: add star feature for tasks to prevent accidental deletion

- Add starred field to HistoryItem type
- Add star button UI component to task items
- Implement toggle star functionality
- Sort starred tasks to the top of the list
- Prevent deletion of starred tasks with warning messages
- Add batch delete protection for starred tasks
- Add localization support for star-related messages

Fixes #6244
This commit is contained in:
Roo Code 2025-07-26 13:34:06 +00:00
parent 62160758fc
commit 67b29f2731
9 changed files with 144 additions and 24 deletions

View file

@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
totalCost: z.number(),
size: z.number().optional(),
workspace: z.string().optional(),
starred: z.boolean().optional(),
})
export type HistoryItem = z.infer<typeof historyItemSchema>

View file

@ -440,6 +440,17 @@ export const webviewMessageHandler = async (
case "deleteTaskWithId":
provider.deleteTaskWithId(message.text!)
break
case "toggleTaskStar":
if (message.text) {
const taskHistory = provider.getValue("taskHistory") || []
const taskIndex = taskHistory.findIndex((task) => task.id === message.text)
if (taskIndex !== -1) {
taskHistory[taskIndex].starred = !taskHistory[taskIndex].starred
await provider.setValue("taskHistory", taskHistory)
await provider.postStateToWebview()
}
}
break
case "deleteMultipleTasksWithIds": {
const ids = message.ids

View file

@ -59,6 +59,7 @@ export interface WebviewMessage {
| "deleteTaskWithId"
| "exportTaskWithId"
| "importSettings"
| "toggleTaskStar"
| "exportSettings"
| "resetState"
| "flushRouterModels"

View file

@ -1,4 +1,4 @@
import { useCallback } from "react"
import { useCallback, useMemo } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import {
AlertDialog,
@ -13,6 +13,7 @@ import {
} from "@/components/ui"
import { vscode } from "@/utils/vscode"
import { AlertDialogProps } from "@radix-ui/react-alert-dialog"
import { useExtensionState } from "@/context/ExtensionStateContext"
interface BatchDeleteTaskDialogProps extends AlertDialogProps {
taskIds: string[]
@ -21,13 +22,31 @@ interface BatchDeleteTaskDialogProps extends AlertDialogProps {
export const BatchDeleteTaskDialog = ({ taskIds, ...props }: BatchDeleteTaskDialogProps) => {
const { t } = useAppTranslation()
const { onOpenChange } = props
const { taskHistory } = useExtensionState()
// Filter out starred tasks
const { deletableTaskIds, starredCount } = useMemo(() => {
const deletable: string[] = []
let starred = 0
taskIds.forEach((id) => {
const task = taskHistory.find((item) => item.id === id)
if (task?.starred) {
starred++
} else {
deletable.push(id)
}
})
return { deletableTaskIds: deletable, starredCount: starred }
}, [taskIds, taskHistory])
const onDelete = useCallback(() => {
if (taskIds.length > 0) {
vscode.postMessage({ type: "deleteMultipleTasksWithIds", ids: taskIds })
if (deletableTaskIds.length > 0) {
vscode.postMessage({ type: "deleteMultipleTasksWithIds", ids: deletableTaskIds })
onOpenChange?.(false)
}
}, [taskIds, onOpenChange])
}, [deletableTaskIds, onOpenChange])
return (
<AlertDialog {...props}>
@ -35,22 +54,41 @@ export const BatchDeleteTaskDialog = ({ taskIds, ...props }: BatchDeleteTaskDial
<AlertDialogHeader>
<AlertDialogTitle>{t("history:deleteTasks")}</AlertDialogTitle>
<AlertDialogDescription className="text-vscode-foreground">
<div className="mb-2">{t("history:confirmDeleteTasks", { count: taskIds.length })}</div>
<div className="text-vscode-editor-foreground bg-vscode-editor-background p-2 rounded text-sm">
{t("history:deleteTasksWarning")}
</div>
{starredCount > 0 ? (
<>
<div className="mb-2 text-vscode-notificationsWarningIcon-foreground">
{t("history:starredTasksExcluded", { count: starredCount })}
</div>
{deletableTaskIds.length > 0 && (
<div className="mb-2">
{t("history:confirmDeleteTasks", { count: deletableTaskIds.length })}
</div>
)}
</>
) : (
<div className="mb-2">
{t("history:confirmDeleteTasks", { count: deletableTaskIds.length })}
</div>
)}
{deletableTaskIds.length > 0 && (
<div className="text-vscode-editor-foreground bg-vscode-editor-background p-2 rounded text-sm">
{t("history:deleteTasksWarning")}
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="secondary">{t("history:cancel")}</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onClick={onDelete}>
<span className="codicon codicon-trash mr-1"></span>
{t("history:deleteItems", { count: taskIds.length })}
</Button>
</AlertDialogAction>
{deletableTaskIds.length > 0 && (
<AlertDialogAction asChild>
<Button variant="destructive" onClick={onDelete}>
<span className="codicon codicon-trash mr-1"></span>
{t("history:deleteItems", { count: deletableTaskIds.length })}
</Button>
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View file

@ -14,6 +14,7 @@ import {
Button,
} from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
@ -24,9 +25,14 @@ interface DeleteTaskDialogProps extends AlertDialogProps {
export const DeleteTaskDialog = ({ taskId, ...props }: DeleteTaskDialogProps) => {
const { t } = useAppTranslation()
const [isEnterPressed] = useKeyPress("Enter")
const { taskHistory } = useExtensionState()
const { onOpenChange } = props
// Check if the task is starred
const task = taskHistory.find((item) => item.id === taskId)
const isStarred = task?.starred || false
const onDelete = useCallback(() => {
if (taskId) {
vscode.postMessage({ type: "deleteTaskWithId", text: taskId })
@ -35,27 +41,33 @@ export const DeleteTaskDialog = ({ taskId, ...props }: DeleteTaskDialogProps) =>
}, [taskId, onOpenChange])
useEffect(() => {
if (taskId && isEnterPressed) {
if (taskId && isEnterPressed && !isStarred) {
onDelete()
}
}, [taskId, isEnterPressed, onDelete])
}, [taskId, isEnterPressed, isStarred, onDelete])
return (
<AlertDialog {...props}>
<AlertDialogContent onEscapeKeyDown={() => onOpenChange?.(false)}>
<AlertDialogHeader>
<AlertDialogTitle>{t("history:deleteTask")}</AlertDialogTitle>
<AlertDialogDescription>{t("history:deleteTaskMessage")}</AlertDialogDescription>
<AlertDialogTitle>
{isStarred ? t("history:deleteStarredTask") : t("history:deleteTask")}
</AlertDialogTitle>
<AlertDialogDescription>
{isStarred ? t("history:deleteStarredTaskMessage") : t("history:deleteTaskMessage")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="secondary">{t("history:cancel")}</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onClick={onDelete}>
{t("history:delete")}
</Button>
</AlertDialogAction>
{!isStarred && (
<AlertDialogAction asChild>
<Button variant="destructive" onClick={onDelete}>
{t("history:delete")}
</Button>
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View file

@ -0,0 +1,40 @@
import React from "react"
import { cn } from "@/lib/utils"
import { StandardTooltip } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface StarButtonProps {
itemId: string
isStarred?: boolean
onToggleStar: (itemId: string) => void
className?: string
}
export const StarButton: React.FC<StarButtonProps> = ({ itemId, isStarred, onToggleStar, className }) => {
const { t } = useAppTranslation()
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
onToggleStar(itemId)
}
return (
<StandardTooltip content={isStarred ? t("history:unstarTask") : t("history:starTask")}>
<button
onClick={handleClick}
className={cn(
"p-1 rounded hover:bg-vscode-toolbar-hoverBackground transition-colors",
"focus:outline-none focus:ring-1 focus:ring-vscode-focusBorder",
className,
)}
aria-label={isStarred ? t("history:unstarTask") : t("history:starTask")}>
<span
className={cn("codicon", {
"codicon-star-full text-vscode-notificationsWarningIcon-foreground": isStarred,
"codicon-star-empty": !isStarred,
})}
/>
</button>
</StandardTooltip>
)
}

View file

@ -2,7 +2,9 @@ import React from "react"
import type { HistoryItem } from "@roo-code/types"
import { formatDate } from "@/utils/format"
import { DeleteButton } from "./DeleteButton"
import { StarButton } from "./StarButton"
import { cn } from "@/lib/utils"
import { vscode } from "@/utils/vscode"
export interface TaskItemHeaderProps {
item: HistoryItem
@ -11,12 +13,16 @@ export interface TaskItemHeaderProps {
}
const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, isSelectionMode, onDelete }) => {
const handleToggleStar = (itemId: string) => {
vscode.postMessage({ type: "toggleTaskStar", text: itemId })
}
return (
<div
className={cn("flex justify-between items-center", {
// this is to balance out the margin when we don't have a delete button
// because the delete button sorta pushes the date up due to its size
"mb-1": !onDelete,
"mb-1": !onDelete && !item.starred,
})}>
<div className="flex items-center flex-wrap gap-x-2 text-xs">
<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
@ -27,6 +33,7 @@ const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, isSelectionMode,
{/* Action Buttons */}
{!isSelectionMode && (
<div className="flex flex-row gap-0 items-center opacity-20 group-hover:opacity-50 hover:opacity-100">
<StarButton itemId={item.id} isStarred={item.starred} onToggleStar={handleToggleStar} />
{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
</div>
)}

View file

@ -59,6 +59,11 @@ export const useTaskSearch = () => {
// Then sort the results
return [...results].sort((a, b) => {
// Always sort starred items to the top
if (a.starred && !b.starred) return -1
if (!a.starred && b.starred) return 1
// If both are starred or both are not starred, apply the selected sort
switch (sortOption) {
case "oldest":
return (a.ts || 0) - (b.ts || 0)

View file

@ -15,6 +15,11 @@
"exportTask": "Export Task",
"deleteTask": "Delete Task",
"deleteTaskMessage": "Are you sure you want to delete this task? This action cannot be undone.",
"deleteStarredTask": "Cannot Delete Starred Task",
"deleteStarredTaskMessage": "This task is starred and cannot be deleted. Please unstar it first if you want to delete it.",
"starTask": "Star task",
"unstarTask": "Unstar task",
"starredTasksExcluded": "{{count}} starred task(s) will not be deleted",
"cancel": "Cancel",
"delete": "Delete",
"exitSelection": "Exit Selection",