Files
paperclip/ui/src/pages/Issues.tsx
Matt Van Horn bc5b30eccf feat(ui): add project filter to issues list
Add a "Project" filter section to the issues filter popover, following the
same pattern as the existing Assignee and Labels filters. Issues can now
be filtered by one or more projects from the filter dropdown.

Closes #129

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:57:01 -07:00

111 lines
3.7 KiB
TypeScript

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";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { CircleDot } from "lucide-react";
export function Issues() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const initialSearch = searchParams.get("q") ?? "";
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearchChange = useCallback((search: string) => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const trimmedSearch = search.trim();
const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
if (currentSearch === trimmedSearch) return;
const url = new URL(window.location.href);
if (trimmedSearch) {
url.searchParams.set("q", trimmedSearch);
} else {
url.searchParams.delete("q");
}
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(window.history.state, "", nextUrl);
}, 300);
}, []);
useEffect(() => {
return () => clearTimeout(debounceRef.current);
}, []);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
useEffect(() => {
setBreadcrumbs([{ label: "Issues" }]);
}, [setBreadcrumbs]);
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
},
});
if (!selectedCompanyId) {
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
}
return (
<IssuesList
issues={issues ?? []}
isLoading={isLoading}
error={error as Error | null}
agents={agents}
projects={projects}
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 })}
/>
);
}