From 2d21045424347a336e1249299b5109f683719e3c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 07:46:44 -0600 Subject: [PATCH] feat(ui): sync issues search with URL query parameter Debounces search input (300ms) and syncs it to a ?q= URL parameter so searches persist across navigation and can be shared via URL. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 11 +++++++++-- ui/src/pages/Issues.tsx | 27 +++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index ef0362d5..dc78cf3a 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -142,6 +142,8 @@ interface IssuesListProps { projectId?: string; viewStateKey: string; initialAssignees?: string[]; + initialSearch?: string; + onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -154,6 +156,8 @@ export function IssuesList({ projectId, viewStateKey, initialAssignees, + initialSearch, + onSearchChange, onUpdateIssue, }: IssuesListProps) { const { selectedCompanyId } = useCompany(); @@ -170,7 +174,7 @@ export function IssuesList({ }); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); - const [issueSearch, setIssueSearch] = useState(""); + const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const deferredIssueSearch = useDeferredValue(issueSearch); const normalizedIssueSearch = deferredIssueSearch.trim(); @@ -291,7 +295,10 @@ export function IssuesList({ setIssueSearch(e.target.value)} + onChange={(e) => { + setIssueSearch(e.target.value); + onSearchChange?.(e.target.value); + }} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 84eb3ab4..d11ed14a 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useCallback, useRef } from "react"; import { useSearchParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; @@ -14,9 +14,30 @@ import { CircleDot } from "lucide-react"; export function Issues() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); + const initialSearch = searchParams.get("q") ?? ""; + const debounceRef = useRef>(undefined); + const handleSearchChange = useCallback((search: string) => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (search.trim()) { + next.set("q", search.trim()); + } else { + next.delete("q"); + } + return next; + }, { replace: true }); + }, 300); + }, [setSearchParams]); + + useEffect(() => { + return () => clearTimeout(debounceRef.current); + }, []); + const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), @@ -69,6 +90,8 @@ export function Issues() { liveIssueIds={liveIssueIds} viewStateKey="paperclip:issues-view" initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} + initialSearch={initialSearch} + onSearchChange={handleSearchChange} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} /> );