diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..04cde4606 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { SquarePenIcon, TerminalIcon, TriangleAlertIcon, + X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { @@ -40,7 +41,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { cn, isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -84,6 +85,8 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { Project } from "~/types"; +import { Input } from "./ui/input"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -310,6 +313,7 @@ export default function Sidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const shouldBrowseForProjectImmediately = isElectron; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const [searchValue, setSearchValue] = useState(""); // state for search/filter const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -1259,6 +1263,45 @@ export default function Sidebar() { ); + // Filtering logic for projects and threads + const filteredProjects = useMemo(() => { + if (!searchValue.trim()) return projects; + // Filter projects where the project name matches or any thread matches + const normalizedSearch = searchValue.toLowerCase(); + return projects.filter((project) => { + const nameMatch = project.name.toLowerCase().includes(normalizedSearch); + // Find threads for this project + const projectThreads = threads.filter((thread) => thread.projectId === project.id); + const threadMatch = projectThreads.some((thread) => + thread.title.toLowerCase().includes(normalizedSearch), + ); + return nameMatch || threadMatch; + }); + }, [projects, threads, searchValue]); + + // Filtering threads within a project if searching, otherwise normal + const getProjectThreads = useCallback( + (project: Project) => { + const projectThreads = threads + .filter((thread) => thread.projectId === project.id) + .toSorted((a, b) => { + const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (byDate !== 0) return byDate; + return b.id.localeCompare(a.id); + }); + + if (!searchValue.trim()) { + return projectThreads; + } + // Only threads matching search for this project + const normalizedSearch = searchValue.toLowerCase(); + return projectThreads.filter((thread) => + thread.title.toLowerCase().includes(normalizedSearch), + ); + }, + [threads, searchValue], + ); + return ( <> {isElectron ? ( @@ -1317,6 +1360,35 @@ export default function Sidebar() { ) : null} + {/* Search Input UI */} +
+ +
+ setSearchValue(e.target.value)} + placeholder="Search projects/threads" + aria-label="Search projects and threads" + className="border-0 border-b border-primary focus:ring-0 focus:border-accent focus:outline-none" + autoComplete="off" + /> + {searchValue && ( + + )} +
+
Projects @@ -1418,20 +1490,14 @@ export default function Sidebar() { > project.id)} + items={filteredProjects.map((project) => project.id)} strategy={verticalListSortingStrategy} > - {projects.map((project) => { - const projectThreads = threads - .filter((thread) => thread.projectId === project.id) - .toSorted((a, b) => { - const byDate = - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - }); + {filteredProjects.map((project) => { + const projectThreads = getProjectThreads(project); const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const hasHiddenThreads = + projectThreads.length > THREAD_PREVIEW_LIMIT && !searchValue.trim(); const visibleThreads = hasHiddenThreads && !isThreadListExpanded ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) @@ -1717,6 +1783,14 @@ export default function Sidebar() { )} + {/* If search is active and no threads to show in this project, show "No threads found" */} + {searchValue.trim() && visibleThreads.length === 0 && ( + +
+ No threads found +
+
+ )} @@ -1728,9 +1802,9 @@ export default function Sidebar() {
- {projects.length === 0 && !shouldShowProjectPathEntry && ( + {filteredProjects.length === 0 && !shouldShowProjectPathEntry && (
- No projects yet + {searchValue.trim() ? "No projects or threads found" : "No projects yet"}
)}