import { useEffect, useDeferredValue, useMemo, useState, useCallback, useRef } from "react"; import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import type { Issue } from "@paperclipai/shared"; /* ── Helpers ── */ const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; const priorityOrder = ["critical", "high", "medium", "low"]; function statusLabel(status: string): string { return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } /* ── View state ── */ export type IssueViewState = { statuses: string[]; priorities: string[]; assignees: string[]; labels: string[]; sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "none"; viewMode: "list" | "board"; collapsedGroups: string[]; }; const defaultViewState: IssueViewState = { statuses: [], priorities: [], assignees: [], labels: [], sortField: "updated", sortDir: "desc", groupBy: "none", viewMode: "list", collapsedGroups: [], }; const quickFilterPresets = [ { label: "All", statuses: [] as string[] }, { label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] }, { label: "Backlog", statuses: ["backlog"] }, { label: "Done", statuses: ["done", "cancelled"] }, ]; function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); if (raw) return { ...defaultViewState, ...JSON.parse(raw) }; } catch { /* ignore */ } return { ...defaultViewState }; } function saveViewState(key: string, state: IssueViewState) { localStorage.setItem(key, JSON.stringify(state)); } function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; const sa = [...a].sort(); const sb = [...b].sort(); return sa.every((v, i) => v === sb[i]); } function toggleInArray(arr: string[], value: string): string[] { return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; } function applyFilters(issues: Issue[], state: IssueViewState): Issue[] { let result = issues; if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId)); if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); return result; } function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; sorted.sort((a, b) => { switch (state.sortField) { case "status": return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)); case "priority": return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); case "title": return dir * a.title.localeCompare(b.title); case "created": return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); case "updated": return dir * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); default: return 0; } }); return sorted; } function countActiveFilters(state: IssueViewState): number { let count = 0; if (state.statuses.length > 0) count++; if (state.priorities.length > 0) count++; if (state.assignees.length > 0) count++; if (state.labels.length > 0) count++; return count; } /* ── Component ── */ interface Agent { id: string; name: string; } interface IssuesListProps { issues: Issue[]; isLoading?: boolean; error?: Error | null; agents?: Agent[]; liveIssueIds?: Set; projectId?: string; viewStateKey: string; initialAssignees?: string[]; onUpdateIssue: (id: string, data: Record) => void; } export function IssuesList({ issues, isLoading, error, agents, liveIssueIds, projectId, viewStateKey, initialAssignees, onUpdateIssue, }: IssuesListProps) { const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; const [viewState, setViewState] = useState(() => { if (initialAssignees) { return { ...defaultViewState, assignees: initialAssignees, statuses: [] }; } return getViewState(scopedKey); }); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(""); const deferredIssueSearch = useDeferredValue(issueSearch); const normalizedIssueSearch = deferredIssueSearch.trim(); // Reload view state from localStorage when company changes (scopedKey changes). const prevScopedKey = useRef(scopedKey); useEffect(() => { if (prevScopedKey.current !== scopedKey) { prevScopedKey.current = scopedKey; setViewState(initialAssignees ? { ...defaultViewState, assignees: initialAssignees, statuses: [] } : getViewState(scopedKey)); } }, [scopedKey, initialAssignees]); const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; saveViewState(scopedKey, next); return next; }); }, [scopedKey]); const { data: searchedIssues = [] } = useQuery({ queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, }); const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; const filteredByControls = applyFilters(sourceIssues, viewState); return sortIssues(filteredByControls, viewState); }, [issues, searchedIssues, viewState, normalizedIssueSearch]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), queryFn: () => issuesApi.listLabels(selectedCompanyId!), enabled: !!selectedCompanyId, }); const activeFilterCount = countActiveFilters(viewState); const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; } if (viewState.groupBy === "status") { const groups = groupBy(filtered, (i) => i.status); return statusOrder .filter((s) => groups[s]?.length) .map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! })); } if (viewState.groupBy === "priority") { const groups = groupBy(filtered, (i) => i.priority); return priorityOrder .filter((p) => groups[p]?.length) .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); } // assignee const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned"); return Object.keys(groups).map((key) => ({ key, label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); }, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps const newIssueDefaults = (groupKey?: string) => { const defaults: Record = {}; if (projectId) defaults.projectId = projectId; if (groupKey) { if (viewState.groupBy === "status") defaults.status = groupKey; else if (viewState.groupBy === "priority") defaults.priority = groupKey; else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey; } return defaults; }; const assignIssue = (issueId: string, assigneeAgentId: string | null) => { onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); setAssigneePickerIssueId(null); setAssigneeSearch(""); }; return (
{/* Toolbar */}
setIssueSearch(e.target.value)} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" />
{/* View mode toggle */}
{/* Filter */}
Filters {activeFilterCount > 0 && ( )}
{/* Quick filters */}
Quick filters
{quickFilterPresets.map((preset) => { const isActive = arraysEqual(viewState.statuses, preset.statuses); return ( ); })}
{/* Multi-column filter sections */}
{/* Status */}
Status
{statusOrder.map((s) => ( ))}
{/* Priority + Assignee stacked in right column */}
{/* Priority */}
Priority
{priorityOrder.map((p) => ( ))}
{/* Assignee */} {agents && agents.length > 0 && (
Assignee
{agents.map((agent) => ( ))}
)} {labels && labels.length > 0 && (
Labels
{labels.map((label) => ( ))}
)}
{/* Sort (list view only) */} {viewState.viewMode === "list" && (
{([ ["status", "Status"], ["priority", "Priority"], ["title", "Title"], ["created", "Created"], ["updated", "Updated"], ] as const).map(([field, label]) => ( ))}
)} {/* Group (list view only) */} {viewState.viewMode === "list" && (
{([ ["status", "Status"], ["priority", "Priority"], ["assignee", "Assignee"], ["none", "None"], ] as const).map(([value, label]) => ( ))}
)}
{isLoading && } {error &&

{error.message}

} {!isLoading && filtered.length === 0 && viewState.viewMode === "list" && ( openNewIssue(newIssueDefaults())} /> )} {viewState.viewMode === "board" ? ( ) : ( groupedContent.map((group) => ( { updateView({ collapsedGroups: open ? viewState.collapsedGroups.filter((k) => k !== group.key) : [...viewState.collapsedGroups, group.key], }); }} > {group.label && (
{group.label}
)} {group.items.map((issue) => ( {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
{ e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} />
{issue.identifier ?? issue.id.slice(0, 8)} {issue.title} {(issue.labels ?? []).length > 0 && (
{(issue.labels ?? []).slice(0, 3).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 3 && ( +{(issue.labels ?? []).length - 3} )}
)}
{liveIssueIds?.has(issue.id) && ( Live )}
{ setAssigneePickerIssueId(open ? issue.id : null); if (!open) setAssigneeSearch(""); }} > e.stopPropagation()} onPointerDownOutside={() => setAssigneeSearch("")} > setAssigneeSearch(e.target.value)} autoFocus />
{(agents ?? []) .filter((agent) => { if (!assigneeSearch.trim()) return true; return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); }) .map((agent) => ( ))}
{formatDate(issue.createdAt)}
))} )) )}
); }