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
This commit is contained in:
Roo Code 2025-07-26 13:41:15 +00:00
parent 62160758fc
commit 0b7a9a4bbf
13 changed files with 247 additions and 276 deletions

View file

@ -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(),

View file

@ -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<typeof historyItemSchema>

View file

@ -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,

View file

@ -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)

View file

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

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,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 (
<AlertDialog {...props}>
@ -35,22 +48,46 @@ 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>
{hasStarredTasks ? (
<>
<div className="mb-2 text-vscode-errorForeground">
{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.`}
</div>
{unstarredTaskIds.length > 0 && (
<>
<div className="mb-2">
{t("history:confirmDeleteTasks", { count: unstarredTaskIds.length })}
</div>
<div className="text-vscode-editor-foreground bg-vscode-editor-background p-2 rounded text-sm">
{t("history:deleteTasksWarning")}
</div>
</>
)}
</>
) : (
<>
<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>
</>
)}
</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>
{unstarredTaskIds.length > 0 && (
<AlertDialogAction asChild>
<Button variant="destructive" onClick={onDelete}>
<span className="codicon codicon-trash mr-1"></span>
{t("history:deleteItems", { count: unstarredTaskIds.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,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 (
<AlertDialog {...props}>
<AlertDialogContent onEscapeKeyDown={() => onOpenChange?.(false)}>
<AlertDialogHeader>
<AlertDialogTitle>{t("history:deleteTask")}</AlertDialogTitle>
<AlertDialogDescription>{t("history:deleteTaskMessage")}</AlertDialogDescription>
<AlertDialogDescription>
{isStarred
? "This task is starred. Please unstar it before deleting."
: 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

@ -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<TaskItemHeaderProps> = ({ item, isSelectionMode, onDelete }) => {
const handleStarClick = (e: React.MouseEvent) => {
e.stopPropagation()
vscode.postMessage({ type: "toggleTaskStar", text: item.id })
}
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.isStarred,
})}>
<div className="flex items-center flex-wrap gap-x-2 text-xs">
<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
{formatDate(item.ts)}
</span>
{item.isStarred && (
<span className="text-vscode-textPreformat-foreground" title="Starred task">
<i className="codicon codicon-star-full" />
</span>
)}
</div>
{/* Action Buttons */}
{!isSelectionMode && (
<div className="flex flex-row gap-0 items-center opacity-20 group-hover:opacity-50 hover:opacity-100">
<button
onClick={handleStarClick}
className="p-1 hover:bg-vscode-toolbar-hoverBackground rounded"
title={item.isStarred ? "Unstar task" : "Star task"}>
<i className={cn("codicon", item.isStarred ? "codicon-star-full" : "codicon-star-empty")} />
</button>
{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
</div>
)}

View file

@ -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(<BatchDeleteTaskDialog taskIds={[]} open={true} onOpenChange={mockOnOpenChange} />)
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)

View file

@ -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

View file

@ -30,6 +30,13 @@ describe("TaskItemHeader", () => {
it("shows delete button when not in selection mode", () => {
render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={vi.fn()} />)
expect(screen.getByRole("button")).toBeInTheDocument()
expect(screen.getByTestId("delete-task-button")).toBeInTheDocument()
})
it("shows star button when not in selection mode", () => {
render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={vi.fn()} />)
const starButton = screen.getByTitle("Star task")
expect(starButton).toBeInTheDocument()
})
})

View file

@ -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) => `<mark>${text}</mark>`),
}))
import { useExtensionState } from "@/context/ExtensionStateContext"
const mockUseExtensionState = useExtensionState as ReturnType<typeof vi.fn>
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",
},
]
// Mock the useExtensionState hook
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(() => ({
taskHistory: mockTaskHistory,
cwd: "/test",
})),
}))
describe("useTaskSearch", () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseExtensionState.mockReturnValue({
taskHistory: mockTaskHistory,
cwd: "/workspace/project1",
} as any)
})
it("returns all tasks by default", () => {
it("should sort starred tasks to the top", () => {
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")
// 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("filters tasks by current workspace by default", () => {
it("should maintain sort order within starred and unstarred groups", () => {
const { result } = renderHook(() => useTaskSearch())
expect(result.current.tasks).toHaveLength(2)
expect(result.current.tasks.every((task) => task.workspace === "/workspace/project1")).toBe(true)
// 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
// 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("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")
})
it("filters tasks by search query", () => {
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("<mark>Create a React component</mark>")
})
it("automatically switches to mostRelevant when searching", () => {
const { result } = renderHook(() => useTaskSearch())
// Initially lastNonRelevantSort should be "newest" (the default)
expect(result.current.lastNonRelevantSort).toBe("newest")
act(() => {
result.current.setSortOption("oldest")
})
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")
})
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")
})
})

View file

@ -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)