mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
62160758fc
commit
0b7a9a4bbf
13 changed files with 247 additions and 276 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export interface WebviewMessage {
|
|||
| "showTaskWithId"
|
||||
| "deleteTaskWithId"
|
||||
| "exportTaskWithId"
|
||||
| "toggleTaskStar"
|
||||
| "importSettings"
|
||||
| "exportSettings"
|
||||
| "resetState"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue