diff --git a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx index bea79814fa..6c5f66d492 100644 --- a/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx +++ b/webview-ui/src/components/history/__tests__/useTaskSearch.spec.tsx @@ -1,4 +1,6 @@ import { renderHook, act } from "@/utils/test-utils" +import * as path from "path" +import * as os from "os" import type { HistoryItem } from "@roo-code/types" @@ -284,4 +286,156 @@ describe("useTaskSearch", () => { // When not searching, it should fall back to newest expect(result.current.sortOption).toBe("mostRelevant") }) + + describe("Desktop directory handling", () => { + it("should correctly filter tasks when workspace is Desktop on macOS", () => { + const desktopPath = path.join(os.homedir(), "Desktop") + const desktopPathWithSlash = path.join(os.homedir(), "Desktop/") + + const desktopTaskHistory: HistoryItem[] = [ + { + id: "desktop-task-1", + number: 1, + task: "Task created in Desktop", + ts: new Date("2022-02-16T12:00:00").getTime(), + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: desktopPath, + }, + { + id: "desktop-task-2", + number: 2, + task: "Another Desktop task", + ts: new Date("2022-02-17T12:00:00").getTime(), + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: desktopPathWithSlash, // With trailing slash + }, + { + id: "other-task", + number: 3, + task: "Task from different workspace", + ts: new Date("2022-02-15T12:00:00").getTime(), + tokensIn: 150, + tokensOut: 75, + totalCost: 0.05, + workspace: "/workspace/project1", + }, + ] + + mockUseExtensionState.mockReturnValue({ + taskHistory: desktopTaskHistory, + cwd: desktopPath, + } as any) + + const { result } = renderHook(() => useTaskSearch()) + + // Should show both Desktop tasks despite different path formats + expect(result.current.tasks).toHaveLength(2) + expect(result.current.tasks[0].id).toBe("desktop-task-2") + expect(result.current.tasks[1].id).toBe("desktop-task-1") + }) + + it("should handle Desktop path variations on Windows", () => { + // Mock Windows platform + const originalPlatform = process.platform + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }) + + const desktopPath = "C:\\Users\\testuser\\Desktop" + const desktopPathMixed = "C:/Users/testuser/Desktop" + const desktopPathLowerCase = "c:\\users\\testuser\\desktop" + + const windowsDesktopTaskHistory: HistoryItem[] = [ + { + id: "win-desktop-task-1", + number: 1, + task: "Windows Desktop task 1", + ts: new Date("2022-02-16T12:00:00").getTime(), + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: desktopPath, + }, + { + id: "win-desktop-task-2", + number: 2, + task: "Windows Desktop task 2", + ts: new Date("2022-02-17T12:00:00").getTime(), + tokensIn: 200, + tokensOut: 100, + totalCost: 0.02, + workspace: desktopPathMixed, // Mixed separators + }, + { + id: "win-desktop-task-3", + number: 3, + task: "Windows Desktop task 3", + ts: new Date("2022-02-18T12:00:00").getTime(), + tokensIn: 150, + tokensOut: 75, + totalCost: 0.03, + workspace: desktopPathLowerCase, // Different case + }, + ] + + mockUseExtensionState.mockReturnValue({ + taskHistory: windowsDesktopTaskHistory, + cwd: desktopPath, + } as any) + + const { result } = renderHook(() => useTaskSearch()) + + // Should show all Desktop tasks despite path variations + expect(result.current.tasks).toHaveLength(3) + expect(result.current.tasks[0].id).toBe("win-desktop-task-3") + expect(result.current.tasks[1].id).toBe("win-desktop-task-2") + expect(result.current.tasks[2].id).toBe("win-desktop-task-1") + + // Restore original platform + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }) + }) + + it("should not lose tasks when switching between panels with Desktop workspace", () => { + const desktopPath = path.join(os.homedir(), "Desktop") + + const desktopTaskHistory: HistoryItem[] = [ + { + id: "persistent-task-1", + number: 1, + task: "Task that should persist", + ts: new Date("2022-02-16T12:00:00").getTime(), + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: desktopPath, + }, + ] + + // Initial render - tasks should be visible + mockUseExtensionState.mockReturnValue({ + taskHistory: desktopTaskHistory, + cwd: desktopPath, + } as any) + + const { result, rerender } = renderHook(() => useTaskSearch()) + + expect(result.current.tasks).toHaveLength(1) + expect(result.current.tasks[0].id).toBe("persistent-task-1") + + // Simulate switching panels (component remount) + rerender() + + // Tasks should still be visible after remount + expect(result.current.tasks).toHaveLength(1) + expect(result.current.tasks[0].id).toBe("persistent-task-1") + }) + }) }) diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 3969985b98..49f38f6d64 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -3,6 +3,7 @@ import { Fzf } from "fzf" import { highlightFzfMatch } from "@/utils/highlight" import { useExtensionState } from "@/context/ExtensionStateContext" +import { arePathsEqual } from "@/utils/path" type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" @@ -26,7 +27,8 @@ export const useTaskSearch = () => { const presentableTasks = useMemo(() => { let tasks = taskHistory.filter((item) => item.ts && item.task) if (!showAllWorkspaces) { - tasks = tasks.filter((item) => item.workspace === cwd) + // Use arePathsEqual for proper path comparison that handles Desktop directory correctly + tasks = tasks.filter((item) => arePathsEqual(item.workspace, cwd)) } return tasks }, [taskHistory, showAllWorkspaces, cwd]) diff --git a/webview-ui/src/utils/__tests__/path.spec.ts b/webview-ui/src/utils/__tests__/path.spec.ts new file mode 100644 index 0000000000..9e3d9d4113 --- /dev/null +++ b/webview-ui/src/utils/__tests__/path.spec.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import * as path from "path" +import * as os from "os" +import { arePathsEqual } from "../path" + +describe("arePathsEqual", () => { + const originalPlatform = process.platform + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + }) + }) + + describe("cross-platform path comparison", () => { + it("should return true for identical paths", () => { + expect(arePathsEqual("/home/user/project", "/home/user/project")).toBe(true) + expect(arePathsEqual("C:\\Users\\project", "C:\\Users\\project")).toBe(true) + }) + + it("should return true for paths with different separators", () => { + expect(arePathsEqual("/home/user/project", "/home/user/project/")).toBe(true) + expect(arePathsEqual("C:\\Users\\project", "C:\\Users\\project\\")).toBe(true) + }) + + it("should normalize paths with . and .. segments", () => { + expect(arePathsEqual("/home/user/../user/project", "/home/user/project")).toBe(true) + expect(arePathsEqual("/home/./user/project", "/home/user/project")).toBe(true) + }) + + it("should handle undefined and null paths", () => { + expect(arePathsEqual(undefined, undefined)).toBe(true) + expect(arePathsEqual(null as any, null as any)).toBe(true) + expect(arePathsEqual(undefined, "/home/user")).toBe(false) + expect(arePathsEqual("/home/user", undefined)).toBe(false) + }) + + it("should handle empty strings", () => { + expect(arePathsEqual("", "")).toBe(true) + expect(arePathsEqual("", "/home/user")).toBe(false) + expect(arePathsEqual("/home/user", "")).toBe(false) + }) + }) + + describe("Windows-specific behavior", () => { + beforeEach(() => { + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }) + }) + + it("should perform case-insensitive comparison on Windows", () => { + expect(arePathsEqual("C:\\Users\\Project", "c:\\users\\project")).toBe(true) + expect(arePathsEqual("C:\\USERS\\PROJECT", "c:\\Users\\Project")).toBe(true) + }) + + it("should handle mixed separators on Windows", () => { + expect(arePathsEqual("C:\\Users\\Project", "C:/Users/Project")).toBe(true) + expect(arePathsEqual("C:/Users/Project", "C:\\Users\\Project")).toBe(true) + }) + }) + + describe("POSIX-specific behavior", () => { + beforeEach(() => { + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }) + }) + + it("should perform case-sensitive comparison on POSIX systems", () => { + expect(arePathsEqual("/Users/Project", "/users/project")).toBe(false) + expect(arePathsEqual("/Users/Project", "/Users/Project")).toBe(true) + }) + }) + + describe("Desktop directory handling", () => { + it("should correctly compare Desktop paths on macOS", () => { + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }) + + const desktopPath = "/Users/testuser/Desktop" + const desktopPathWithSlash = "/Users/testuser/Desktop/" + const desktopPathNormalized = path.normalize("/Users/testuser/Desktop") + + expect(arePathsEqual(desktopPath, desktopPath)).toBe(true) + expect(arePathsEqual(desktopPath, desktopPathWithSlash)).toBe(true) + expect(arePathsEqual(desktopPath, desktopPathNormalized)).toBe(true) + }) + + it("should correctly compare Desktop paths on Windows", () => { + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }) + + const desktopPath = "C:\\Users\\testuser\\Desktop" + const desktopPathWithSlash = "C:\\Users\\testuser\\Desktop\\" + const desktopPathMixedCase = "c:\\users\\testuser\\desktop" + const desktopPathForwardSlash = "C:/Users/testuser/Desktop" + + expect(arePathsEqual(desktopPath, desktopPath)).toBe(true) + expect(arePathsEqual(desktopPath, desktopPathWithSlash)).toBe(true) + expect(arePathsEqual(desktopPath, desktopPathMixedCase)).toBe(true) + expect(arePathsEqual(desktopPath, desktopPathForwardSlash)).toBe(true) + }) + + it("should handle relative Desktop paths", () => { + const homeDir = os.homedir() + const desktopRelative = path.join("~", "Desktop").replace("~", homeDir) + const desktopAbsolute = path.join(homeDir, "Desktop") + + expect(arePathsEqual(desktopRelative, desktopAbsolute)).toBe(true) + }) + }) + + describe("edge cases", () => { + it("should handle paths with multiple slashes", () => { + expect(arePathsEqual("/home//user///project", "/home/user/project")).toBe(true) + expect(arePathsEqual("C:\\\\Users\\\\\\project", "C:\\Users\\project")).toBe(true) + }) + + it("should handle root paths", () => { + expect(arePathsEqual("/", "/")).toBe(true) + expect(arePathsEqual("C:\\", "C:\\")).toBe(true) + + // Root paths should keep their trailing slash + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }) + expect(arePathsEqual("C:\\", "c:/")).toBe(true) + }) + + it("should return false for different paths", () => { + expect(arePathsEqual("/home/user/project1", "/home/user/project2")).toBe(false) + expect(arePathsEqual("/home/user", "/home/user/project")).toBe(false) + }) + }) +}) diff --git a/webview-ui/src/utils/path.ts b/webview-ui/src/utils/path.ts new file mode 100644 index 0000000000..899dbf9399 --- /dev/null +++ b/webview-ui/src/utils/path.ts @@ -0,0 +1,79 @@ +/** + * Normalizes a path for consistent comparison + * - Resolves ./.. segments + * - Removes duplicate slashes + * - Standardizes path separators + * - Removes trailing slashes (except for root paths) + */ +function normalizePath(p: string): string { + if (!p) return "" + + // First, replace all backslashes with forward slashes for consistency + let normalized = p.replace(/\\/g, "/") + + // Remove duplicate slashes + normalized = normalized.replace(/\/+/g, "/") + + // Handle drive letters on Windows (e.g., C:/ -> C:/) + if (/^[a-zA-Z]:/.test(normalized)) { + // Ensure drive letter is uppercase for consistency + normalized = normalized[0].toUpperCase() + normalized.slice(1) + } + + // Split into parts and resolve . and .. segments + const parts = normalized.split("/") + const resolved: string[] = [] + + for (const part of parts) { + if (part === "..") { + resolved.pop() + } else if (part !== "." && part !== "") { + resolved.push(part) + } else if (part === "" && resolved.length === 0) { + // Keep the first empty part for absolute paths + resolved.push(part) + } + } + + // Rejoin the parts + normalized = resolved.join("/") + + // Ensure absolute paths start with / + if (p.startsWith("/") && !normalized.startsWith("/")) { + normalized = "/" + normalized + } + + // Remove trailing slash, except for root paths + if (normalized.length > 1 && normalized.endsWith("/")) { + // Don't remove trailing slash from root paths like "/" or "C:/" + if (!(normalized === "/" || /^[a-zA-Z]:\/$/i.test(normalized))) { + normalized = normalized.slice(0, -1) + } + } + + return normalized +} + +/** + * Safe path comparison that works across different platforms + * Handles case-insensitive comparison on Windows and normalizes paths + * This is especially important for comparing workspace paths like Desktop directory + */ +export function arePathsEqual(path1?: string, path2?: string): boolean { + if (!path1 && !path2) { + return true + } + if (!path1 || !path2) { + return false + } + + path1 = normalizePath(path1) + path2 = normalizePath(path2) + + // Windows paths are case-insensitive + if (process.platform === "win32") { + return path1.toLowerCase() === path2.toLowerCase() + } + + return path1 === path2 +}