diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index d5e76ecceac..ee1377449f0 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -41,6 +41,7 @@ export const globalSettingsSchema = z.object({ lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), taskHistory: z.array(historyItemSchema).optional(), + starredTaskIds: z.array(z.string()).optional(), condensingApiConfigId: z.string().optional(), customCondensingPrompt: z.string().optional(), diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 8c75024879c..940b780462a 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -16,6 +16,7 @@ export const historyItemSchema = z.object({ totalCost: z.number(), size: z.number().optional(), workspace: z.string().optional(), + isStarred: z.boolean().optional(), }) export type HistoryItem = z.infer diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6bcb85e3378..37bde4319c7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1478,7 +1478,11 @@ export class ClineProvider clineMessages: this.getCurrentCline()?.clineMessages || [], taskHistory: (taskHistory || []) .filter((item: HistoryItem) => item.ts && item.task) - .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), + .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts) + .map((item: HistoryItem) => ({ + ...item, + isStarred: (this.getGlobalState("starredTaskIds") || []).includes(item.id), + })), soundEnabled: soundEnabled ?? false, ttsEnabled: ttsEnabled ?? false, ttsSpeed: ttsSpeed ?? 1.0, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 82c780adf47..95f3840db01 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2345,6 +2345,38 @@ export const webviewMessageHandler = async ( break } + case "toggleTaskStar": { + if (message.text) { + const taskId = message.text + const starredTaskIds = getGlobalState("starredTaskIds") || [] + const isCurrentlyStarred = starredTaskIds.includes(taskId) + + let updatedStarredTaskIds: string[] + if (isCurrentlyStarred) { + // Unstar the task + updatedStarredTaskIds = starredTaskIds.filter((id) => id !== taskId) + } else { + // Star the task + updatedStarredTaskIds = [...starredTaskIds, taskId] + } + + await updateGlobalState("starredTaskIds", updatedStarredTaskIds) + + // Update the task history to reflect the starred status + const taskHistory = getGlobalState("taskHistory") || [] + const updatedTaskHistory = taskHistory.map((task) => { + if (task.id === taskId) { + return { ...task, isStarred: !isCurrentlyStarred } + } + return task + }) + await updateGlobalState("taskHistory", updatedTaskHistory) + + await provider.postStateToWebview() + } + break + } + case "switchTab": { if (message.tab) { // Capture tab shown event for all switchTab messages (which are user-initiated) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 795e2765222..3a3f5022eeb 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -58,6 +58,7 @@ export interface WebviewMessage { | "showTaskWithId" | "deleteTaskWithId" | "exportTaskWithId" + | "toggleTaskStar" | "importSettings" | "exportSettings" | "resetState" diff --git a/webview-ui/src/components/history/BatchDeleteTaskDialog.tsx b/webview-ui/src/components/history/BatchDeleteTaskDialog.tsx index decc905315a..33b06ab3c00 100644 --- a/webview-ui/src/components/history/BatchDeleteTaskDialog.tsx +++ b/webview-ui/src/components/history/BatchDeleteTaskDialog.tsx @@ -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,25 @@ interface BatchDeleteTaskDialogProps extends AlertDialogProps { export const BatchDeleteTaskDialog = ({ taskIds, ...props }: BatchDeleteTaskDialogProps) => { const { t } = useAppTranslation() const { onOpenChange } = props + const { taskHistory } = useExtensionState() + + // Check if any of the selected tasks are starred + const starredTaskIds = useMemo(() => { + return taskIds.filter((id) => { + const task = taskHistory.find((t) => t.id === id) + return task?.isStarred || false + }) + }, [taskIds, taskHistory]) + + const hasStarredTasks = starredTaskIds.length > 0 + const unstarredTaskIds = taskIds.filter((id) => !starredTaskIds.includes(id)) const onDelete = useCallback(() => { - if (taskIds.length > 0) { - vscode.postMessage({ type: "deleteMultipleTasksWithIds", ids: taskIds }) + if (unstarredTaskIds.length > 0) { + vscode.postMessage({ type: "deleteMultipleTasksWithIds", ids: unstarredTaskIds }) onOpenChange?.(false) } - }, [taskIds, onOpenChange]) + }, [unstarredTaskIds, onOpenChange]) return ( @@ -35,22 +48,46 @@ export const BatchDeleteTaskDialog = ({ taskIds, ...props }: BatchDeleteTaskDial {t("history:deleteTasks")} -
{t("history:confirmDeleteTasks", { count: taskIds.length })}
-
- {t("history:deleteTasksWarning")} -
+ {hasStarredTasks ? ( + <> +
+ {starredTaskIds.length === taskIds.length + ? "All selected tasks are starred. Please unstar them before deleting." + : `${starredTaskIds.length} of ${taskIds.length} selected tasks are starred and will not be deleted.`} +
+ {unstarredTaskIds.length > 0 && ( + <> +
+ {t("history:confirmDeleteTasks", { count: unstarredTaskIds.length })} +
+
+ {t("history:deleteTasksWarning")} +
+ + )} + + ) : ( + <> +
{t("history:confirmDeleteTasks", { count: taskIds.length })}
+
+ {t("history:deleteTasksWarning")} +
+ + )}
- - - + {unstarredTaskIds.length > 0 && ( + + + + )}
diff --git a/webview-ui/src/components/history/DeleteTaskDialog.tsx b/webview-ui/src/components/history/DeleteTaskDialog.tsx index d0e3ab16a4d..a82b424b39c 100644 --- a/webview-ui/src/components/history/DeleteTaskDialog.tsx +++ b/webview-ui/src/components/history/DeleteTaskDialog.tsx @@ -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,38 +25,49 @@ 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((t) => t.id === taskId) + const isStarred = task?.isStarred || false + const onDelete = useCallback(() => { - if (taskId) { + if (taskId && !isStarred) { vscode.postMessage({ type: "deleteTaskWithId", text: taskId }) onOpenChange?.(false) } - }, [taskId, onOpenChange]) + }, [taskId, isStarred, onOpenChange]) useEffect(() => { - if (taskId && isEnterPressed) { + if (taskId && isEnterPressed && !isStarred) { onDelete() } - }, [taskId, isEnterPressed, onDelete]) + }, [taskId, isEnterPressed, isStarred, onDelete]) return ( onOpenChange?.(false)}> {t("history:deleteTask")} - {t("history:deleteTaskMessage")} + + {isStarred + ? "This task is starred. Please unstar it before deleting." + : t("history:deleteTaskMessage")} + - - - + {!isStarred && ( + + + + )} diff --git a/webview-ui/src/components/history/TaskItemHeader.tsx b/webview-ui/src/components/history/TaskItemHeader.tsx index bdddb090c86..3153342a526 100644 --- a/webview-ui/src/components/history/TaskItemHeader.tsx +++ b/webview-ui/src/components/history/TaskItemHeader.tsx @@ -3,6 +3,7 @@ import type { HistoryItem } from "@roo-code/types" import { formatDate } from "@/utils/format" import { DeleteButton } from "./DeleteButton" import { cn } from "@/lib/utils" +import { vscode } from "@/utils/vscode" export interface TaskItemHeaderProps { item: HistoryItem @@ -11,22 +12,38 @@ export interface TaskItemHeaderProps { } const TaskItemHeader: React.FC = ({ item, isSelectionMode, onDelete }) => { + const handleStarClick = (e: React.MouseEvent) => { + e.stopPropagation() + vscode.postMessage({ type: "toggleTaskStar", text: item.id }) + } + return (
{formatDate(item.ts)} + {item.isStarred && ( + + + + )}
{/* Action Buttons */} {!isSelectionMode && (
+ {onDelete && }
)} diff --git a/webview-ui/src/components/history/__tests__/BatchDeleteTaskDialog.spec.tsx b/webview-ui/src/components/history/__tests__/BatchDeleteTaskDialog.spec.tsx index bdcff23cddc..451149cc309 100644 --- a/webview-ui/src/components/history/__tests__/BatchDeleteTaskDialog.spec.tsx +++ b/webview-ui/src/components/history/__tests__/BatchDeleteTaskDialog.spec.tsx @@ -21,6 +21,16 @@ vi.mock("@/i18n/TranslationContext", () => ({ }), })) +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + taskHistory: [ + { id: "task-1", isStarred: false }, + { id: "task-2", isStarred: false }, + { id: "task-3", isStarred: false }, + ], + }), +})) + describe("BatchDeleteTaskDialog", () => { const mockTaskIds = ["task-1", "task-2", "task-3"] const mockOnOpenChange = vi.fn() @@ -65,8 +75,13 @@ describe("BatchDeleteTaskDialog", () => { it("does not call vscode.postMessage when taskIds is empty", () => { render() - const deleteButton = screen.getByText("Delete 0 items") - fireEvent.click(deleteButton) + // When there are no tasks, there should be no delete button + const deleteButton = screen.queryByText("Delete 0 items") + expect(deleteButton).not.toBeInTheDocument() + + // Cancel button should still work + const cancelButton = screen.getByText("Cancel") + fireEvent.click(cancelButton) expect(vscode.postMessage).not.toHaveBeenCalled() expect(mockOnOpenChange).toHaveBeenCalledWith(false) diff --git a/webview-ui/src/components/history/__tests__/DeleteTaskDialog.spec.tsx b/webview-ui/src/components/history/__tests__/DeleteTaskDialog.spec.tsx index f8e244e9bfa..b703f083bb3 100644 --- a/webview-ui/src/components/history/__tests__/DeleteTaskDialog.spec.tsx +++ b/webview-ui/src/components/history/__tests__/DeleteTaskDialog.spec.tsx @@ -24,6 +24,15 @@ vi.mock("react-use", () => ({ useKeyPress: vi.fn(), })) +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + taskHistory: [ + { id: "test-task-id", isStarred: false }, + { id: "starred-task-id", isStarred: true }, + ], + }), +})) + import { useKeyPress } from "react-use" const mockUseKeyPress = useKeyPress as any diff --git a/webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx index 090bf2521f7..55ec1d5b758 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx @@ -30,6 +30,13 @@ describe("TaskItemHeader", () => { it("shows delete button when not in selection mode", () => { render() - expect(screen.getByRole("button")).toBeInTheDocument() + expect(screen.getByTestId("delete-task-button")).toBeInTheDocument() + }) + + it("shows star button when not in selection mode", () => { + render() + + const starButton = screen.getByTitle("Star task") + expect(starButton).toBeInTheDocument() }) }) diff --git a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx index bea79814fa1..34dd17c5e36 100644 --- a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx +++ b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx @@ -1,287 +1,117 @@ -import { renderHook, act } from "@/utils/test-utils" - -import type { HistoryItem } from "@roo-code/types" +// cd webview-ui && npx vitest run src/components/history/__tests__/useTaskSearch.spec.tsx +import { renderHook } from "@testing-library/react" +import { describe, it, expect, vi } from "vitest" import { useTaskSearch } from "../useTaskSearch" - -vi.mock("@/context/ExtensionStateContext", () => ({ - useExtensionState: vi.fn(), -})) - -vi.mock("@/utils/highlight", () => ({ - highlightFzfMatch: vi.fn((text) => `${text}`), -})) - -import { useExtensionState } from "@/context/ExtensionStateContext" - -const mockUseExtensionState = useExtensionState as ReturnType +import type { HistoryItem } from "@roo-code/types" const mockTaskHistory: HistoryItem[] = [ { - id: "task-1", number: 1, - task: "Create a React component", - ts: new Date("2022-02-16T12:00:00").getTime(), + id: "task1", + ts: 1000, + task: "Task 1", tokensIn: 100, - tokensOut: 50, + tokensOut: 200, totalCost: 0.01, - workspace: "/workspace/project1", + isStarred: false, + workspace: "/test", }, { - id: "task-2", number: 2, - task: "Write unit tests", - ts: new Date("2022-02-17T12:00:00").getTime(), - tokensIn: 200, - tokensOut: 100, + id: "task2", + ts: 2000, + task: "Task 2", + tokensIn: 150, + tokensOut: 250, totalCost: 0.02, - cacheWrites: 25, - cacheReads: 10, - workspace: "/workspace/project1", + isStarred: true, + workspace: "/test", }, { - id: "task-3", number: 3, - task: "Fix bug in authentication", - ts: new Date("2022-02-15T12:00:00").getTime(), - tokensIn: 150, - tokensOut: 75, - totalCost: 0.05, - workspace: "/workspace/project2", + id: "task3", + ts: 3000, + task: "Task 3", + tokensIn: 200, + tokensOut: 300, + totalCost: 0.03, + isStarred: false, + workspace: "/test", + }, + { + number: 4, + id: "task4", + ts: 4000, + task: "Task 4", + tokensIn: 250, + tokensOut: 350, + totalCost: 0.04, + isStarred: true, + workspace: "/test", }, ] -describe("useTaskSearch", () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseExtensionState.mockReturnValue({ - taskHistory: mockTaskHistory, - cwd: "/workspace/project1", - } as any) - }) - - it("returns all tasks by default", () => { - const { result } = renderHook(() => useTaskSearch()) - - expect(result.current.tasks).toHaveLength(2) // Only tasks from current workspace - expect(result.current.tasks[0].id).toBe("task-2") // Newest first - expect(result.current.tasks[1].id).toBe("task-1") - }) - - it("filters tasks by current workspace by default", () => { - const { result } = renderHook(() => useTaskSearch()) - - expect(result.current.tasks).toHaveLength(2) - expect(result.current.tasks.every((task) => task.workspace === "/workspace/project1")).toBe(true) - }) - - it("shows all workspaces when showAllWorkspaces is true", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setShowAllWorkspaces(true) - }) - - expect(result.current.tasks).toHaveLength(3) - expect(result.current.showAllWorkspaces).toBe(true) - }) - - it("sorts by newest by default", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setShowAllWorkspaces(true) - }) - - expect(result.current.sortOption).toBe("newest") - expect(result.current.tasks[0].id).toBe("task-2") // Feb 17 - expect(result.current.tasks[1].id).toBe("task-1") // Feb 16 - expect(result.current.tasks[2].id).toBe("task-3") // Feb 15 - }) - - it("sorts by oldest", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setShowAllWorkspaces(true) - result.current.setSortOption("oldest") - }) - - expect(result.current.tasks[0].id).toBe("task-3") // Feb 15 - expect(result.current.tasks[1].id).toBe("task-1") // Feb 16 - expect(result.current.tasks[2].id).toBe("task-2") // Feb 17 - }) - - it("sorts by most expensive", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setShowAllWorkspaces(true) - result.current.setSortOption("mostExpensive") - }) - - expect(result.current.tasks[0].id).toBe("task-3") // $0.05 - expect(result.current.tasks[1].id).toBe("task-2") // $0.02 - expect(result.current.tasks[2].id).toBe("task-1") // $0.01 - }) - - it("sorts by most tokens", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setShowAllWorkspaces(true) - result.current.setSortOption("mostTokens") - }) - - // task-2: 200 + 100 + 25 + 10 = 335 tokens - // task-3: 150 + 75 = 225 tokens - // task-1: 100 + 50 = 150 tokens - expect(result.current.tasks[0].id).toBe("task-2") - expect(result.current.tasks[1].id).toBe("task-3") - expect(result.current.tasks[2].id).toBe("task-1") - }) +// Mock the useExtensionState hook +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn(() => ({ + taskHistory: mockTaskHistory, + cwd: "/test", + })), +})) - it("filters tasks by search query", () => { +describe("useTaskSearch", () => { + it("should sort starred tasks to the top", () => { const { result } = renderHook(() => useTaskSearch()) - act(() => { - result.current.setShowAllWorkspaces(true) - result.current.setSearchQuery("React") - }) - - expect(result.current.tasks).toHaveLength(1) - expect(result.current.tasks[0].id).toBe("task-1") - expect((result.current.tasks[0] as any).highlight).toBe("Create a React component") + // Check that starred tasks (task2 and task4) are at the top + expect(result.current.tasks[0].id).toBe("task4") + expect(result.current.tasks[1].id).toBe("task2") + expect(result.current.tasks[2].id).toBe("task3") + expect(result.current.tasks[3].id).toBe("task1") }) - it("automatically switches to mostRelevant when searching", () => { + it("should maintain sort order within starred and unstarred groups", () => { const { result } = renderHook(() => useTaskSearch()) - // Initially lastNonRelevantSort should be "newest" (the default) - expect(result.current.lastNonRelevantSort).toBe("newest") - - act(() => { - result.current.setSortOption("oldest") - }) + // Starred tasks should be sorted by newest first + const starredTasks = result.current.tasks.filter((t) => t.isStarred) + expect(starredTasks[0].id).toBe("task4") // newer + expect(starredTasks[1].id).toBe("task2") // older - expect(result.current.sortOption).toBe("oldest") - - // Clear lastNonRelevantSort to test the auto-switch behavior - act(() => { - result.current.setLastNonRelevantSort(null) - }) - - act(() => { - result.current.setSearchQuery("test") - }) - - // The hook should automatically switch to mostRelevant when there's a search query - // and the current sort is not mostRelevant and lastNonRelevantSort is null - expect(result.current.sortOption).toBe("mostRelevant") - expect(result.current.lastNonRelevantSort).toBe("oldest") + // Unstarred tasks should also be sorted by newest first + const unstarredTasks = result.current.tasks.filter((t) => !t.isStarred) + expect(unstarredTasks[0].id).toBe("task3") // newer + expect(unstarredTasks[1].id).toBe("task1") // older }) - it("restores previous sort when clearing search", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setSortOption("mostExpensive") - }) - - expect(result.current.sortOption).toBe("mostExpensive") - - // Clear lastNonRelevantSort to enable the auto-switch behavior - act(() => { - result.current.setLastNonRelevantSort(null) - }) - - act(() => { - result.current.setSearchQuery("test") - }) - - expect(result.current.sortOption).toBe("mostRelevant") - expect(result.current.lastNonRelevantSort).toBe("mostExpensive") - - act(() => { - result.current.setSearchQuery("") - }) - - expect(result.current.sortOption).toBe("mostExpensive") - expect(result.current.lastNonRelevantSort).toBe(null) - }) - - it("handles empty task history", () => { - mockUseExtensionState.mockReturnValue({ + it("should handle empty task history", async () => { + const { useExtensionState } = await import("@/context/ExtensionStateContext") + vi.mocked(useExtensionState).mockReturnValueOnce({ taskHistory: [], - cwd: "/workspace/project1", + cwd: "/test", } as any) const { result } = renderHook(() => useTaskSearch()) - expect(result.current.tasks).toHaveLength(0) + expect(result.current.tasks).toEqual([]) }) - it("filters out tasks without timestamp or task content", () => { - const incompleteTaskHistory = [ - ...mockTaskHistory, - { - id: "incomplete-1", - number: 4, - task: "", - ts: Date.now(), - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - }, - { - id: "incomplete-2", - number: 5, - task: "Valid task", - ts: 0, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - }, - ] as HistoryItem[] - - mockUseExtensionState.mockReturnValue({ - taskHistory: incompleteTaskHistory, - cwd: "/workspace/project1", + it("should handle all starred tasks", async () => { + const allStarredTasks = mockTaskHistory.map((task) => ({ ...task, isStarred: true })) + const { useExtensionState } = await import("@/context/ExtensionStateContext") + vi.mocked(useExtensionState).mockReturnValueOnce({ + taskHistory: allStarredTasks, + cwd: "/test", } as any) const { result } = renderHook(() => useTaskSearch()) - act(() => { - result.current.setShowAllWorkspaces(true) - }) - - // Should only include tasks with both ts and task content - expect(result.current.tasks).toHaveLength(3) - expect(result.current.tasks.every((task) => task.ts && task.task)).toBe(true) - }) - - it("handles search with no results", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setShowAllWorkspaces(true) - result.current.setSearchQuery("nonexistent") - }) - - expect(result.current.tasks).toHaveLength(0) - }) - - it("preserves search results order when using mostRelevant sort", () => { - const { result } = renderHook(() => useTaskSearch()) - - act(() => { - result.current.setShowAllWorkspaces(true) - result.current.setSearchQuery("test") - result.current.setSortOption("mostRelevant") - }) - - // When searching, mostRelevant should preserve fzf order - // When not searching, it should fall back to newest - expect(result.current.sortOption).toBe("mostRelevant") + // All tasks should be present and sorted by newest first + expect(result.current.tasks.length).toBe(4) + expect(result.current.tasks[0].id).toBe("task4") + expect(result.current.tasks[1].id).toBe("task3") + expect(result.current.tasks[2].id).toBe("task2") + expect(result.current.tasks[3].id).toBe("task1") }) }) diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 3969985b98a..21c76fe78c2 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -59,6 +59,11 @@ export const useTaskSearch = () => { // Then sort the results return [...results].sort((a, b) => { + // Always prioritize starred tasks + if (a.isStarred && !b.isStarred) return -1 + if (!a.isStarred && b.isStarred) 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)