import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { DEFAULT_INBOX_ISSUE_COLUMNS, getAvailableInboxIssueColumns, loadInboxIssueColumns, normalizeInboxIssueColumns, resolveIssueWorkspaceName, saveInboxIssueColumns, type InboxIssueColumn, } from "../lib/inbox"; import { cn } from "../lib/utils"; import { InboxIssueMetaLeading, InboxIssueTrailingColumns, IssueColumnPicker, issueActivityText, issueTrailingColumns, } from "./IssueColumns"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; import { IssueRow } from "./IssueRow"; 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 { buildIssueTree, countDescendants } from "../lib/issue-tree"; import type { Issue, Project } from "@paperclipai/shared"; /* ── Helpers ── */ const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; const priorityOrder = ["critical", "high", "medium", "low"]; const ISSUE_SEARCH_DEBOUNCE_MS = 150; 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[]; projects: string[]; sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; viewMode: "list" | "board"; collapsedGroups: string[]; collapsedParents: string[]; }; const defaultViewState: IssueViewState = { statuses: [], priorities: [], assignees: [], labels: [], projects: [], sortField: "updated", sortDir: "desc", groupBy: "none", viewMode: "list", collapsedGroups: [], collapsedParents: [], }; 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, currentUserId?: string | null): 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((issue) => { for (const assignee of state.assignees) { if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; if (issue.assigneeAgentId === assignee) return true; } return false; }); } if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId)); 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++; if (state.projects.length > 0) count++; return count; } /* ── Component ── */ interface Agent { id: string; name: string; } type ProjectOption = Pick & Partial>; interface IssuesListProps { issues: Issue[]; isLoading?: boolean; error?: Error | null; agents?: Agent[]; projects?: ProjectOption[]; liveIssueIds?: Set; projectId?: string; viewStateKey: string; issueLinkState?: unknown; initialAssignees?: string[]; initialSearch?: string; searchFilters?: { participantAgentId?: string; }; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } function IssueSearchInput({ value, onDebouncedChange, }: { value: string; onDebouncedChange?: (search: string) => void; }) { const [draftValue, setDraftValue] = useState(value); const lastCommittedValueRef = useRef(value); useEffect(() => { setDraftValue(value); lastCommittedValueRef.current = value; }, [value]); useEffect(() => { if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return; const timeoutId = window.setTimeout(() => { lastCommittedValueRef.current = draftValue; startTransition(() => { onDebouncedChange(draftValue); }); }, ISSUE_SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); }, [draftValue, onDebouncedChange]); return (
{ setDraftValue(e.target.value); }} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" />
); } export function IssuesList({ issues, isLoading, error, agents, projects, liveIssueIds, projectId, viewStateKey, issueLinkState, initialAssignees, initialSearch, searchFilters, onSearchChange, onUpdateIssue, }: IssuesListProps) { const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; // 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(initialSearch ?? ""); const [visibleIssueColumns, setVisibleIssueColumns] = useState(loadInboxIssueColumns); const deferredIssueSearch = useDeferredValue(issueSearch); const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); useEffect(() => { setIssueSearch(initialSearch ?? ""); }, [initialSearch]); // 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]); // Prune stale IDs from collapsedParents whenever the issue list changes. // Deleted or reassigned issues leave orphan IDs in localStorage; this keeps // the stored array bounded to only current parent IDs. useEffect(() => { const parentIds = new Set(issues.map((i) => i.parentId).filter(Boolean) as string[]); const pruned = viewState.collapsedParents.filter((id) => parentIds.has(id)); if (pruned.length !== viewState.collapsedParents.length) { updateView({ collapsedParents: pruned }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [issues]); const { data: searchedIssues = [] } = useQuery({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, ], queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, placeholderData: (previousData) => previousData, }); const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.list(selectedCompanyId) : ["execution-workspaces", "__disabled__"], queryFn: () => executionWorkspacesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && isolatedWorkspacesEnabled, }); const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); const projectById = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { map.set(project.id, { name: project.name, color: project.color ?? null }); } return map; }, [projects]); const projectWorkspaceById = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { for (const workspace of project.workspaces ?? []) { map.set(workspace.id, { name: workspace.name || project.name }); } } return map; }, [projects]); const defaultProjectWorkspaceIdByProjectId = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { const defaultWorkspaceId = project.executionWorkspacePolicy?.defaultProjectWorkspaceId ?? project.primaryWorkspace?.id ?? null; if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId); } return map; }, [projects]); const executionWorkspaceById = useMemo(() => { const map = new Map(); for (const workspace of executionWorkspaces) { map.set(workspace.id, { name: workspace.name, mode: workspace.mode, projectWorkspaceId: workspace.projectWorkspaceId ?? null, }); } return map; }, [executionWorkspaces]); const workspaceNameMap = useMemo(() => { const map = new Map(); for (const [workspaceId, workspace] of projectWorkspaceById) { map.set(workspaceId, workspace.name); } for (const [workspaceId, workspace] of executionWorkspaceById) { map.set(workspaceId, workspace.name); } return map; }, [executionWorkspaceById, projectWorkspaceById]); const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), [isolatedWorkspacesEnabled], ); const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]); const visibleTrailingIssueColumns = useMemo( () => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)), [availableIssueColumnSet, visibleIssueColumnSet], ); const issueById = useMemo(() => { const map = new Map(); for (const issue of issues) { map.set(issue.id, issue); } return map; }, [issues]); const issueTitleMap = useMemo(() => { const map = new Map(); for (const issue of issues) { map.set(issue.id, issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title); } return map; }, [issues]); const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); 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]! })); } if (viewState.groupBy === "workspace") { const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no workspace" last if (a === "__no_workspace") return 1; if (b === "__no_workspace") return -1; return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0); }) .map((key) => ({ key, label: key === "__no_workspace" ? "No Workspace" : (workspaceNameMap.get(key) ?? key.slice(0, 8)), items: groups[key]!, })); } if (viewState.groupBy === "parent") { const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent"); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no parent" last if (a === "__no_parent") return 1; if (b === "__no_parent") return -1; return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0); }) .map((key) => ({ key, label: key === "__no_parent" ? "No Parent" : (issueTitleMap.get(key) ?? key.slice(0, 8)), items: groups[key]!, })); } // assignee const groups = groupBy( filtered, (issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"), ); return Object.keys(groups).map((key) => ({ key, label: key === "__unassigned" ? "Unassigned" : key.startsWith("__user:") ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User") : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]); const newIssueDefaults = useCallback((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") { if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); else defaults.assigneeAgentId = groupKey; } else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") { defaults.parentId = groupKey; } } return defaults; }, [projectId, viewState.groupBy]); const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); saveInboxIssueColumns(normalized); }, []); const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => { if (enabled) { setIssueColumns([...visibleIssueColumns, column]); return; } setIssueColumns(visibleIssueColumns.filter((value) => value !== column)); }, [setIssueColumns, visibleIssueColumns]); const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); }, [onUpdateIssue]); return (
{/* Toolbar */}
{ setIssueSearch(nextSearch); onSearchChange?.(nextSearch); }} />
{/* View mode toggle */}
setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which issue columns stay visible" /> {/* 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 */}
Assignee
{currentUserId && ( )} {(agents ?? []).map((agent) => ( ))}
{labels && labels.length > 0 && (
Labels
{labels.map((label) => ( ))}
)} {projects && projects.length > 0 && (
Project
{projects.map((project) => ( ))}
)}
{/* 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"], ["workspace", "Workspace"], ["parent", "Parent Issue"], ["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}
)} {(() => { const { roots, childMap } = buildIssueTree(group.items); const renderIssueRow = (issue: Issue, depth: number) => { const children = childMap.get(issue.id) ?? []; const hasChildren = children.length > 0; const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0; const isExpanded = !viewState.collapsedParents.includes(issue.id); const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null; const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null; const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => { e.preventDefault(); e.stopPropagation(); updateView({ collapsedParents: isExpanded ? [...viewState.collapsedParents, issue.id] : viewState.collapsedParents.filter((id) => id !== issue.id), }); }; return (
0 ? { paddingLeft: `${depth * 16}px` } : undefined}> ({totalDescendants} sub-task{totalDescendants !== 1 ? "s" : ""}) ) : undefined} mobileLeading={ hasChildren ? ( ) : ( { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> ) } desktopMetaLeading={( <> {hasChildren ? ( ) : ( )} { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> )} /> )} mobileMeta={issueActivityText(issue).toLowerCase()} desktopTrailing={( visibleTrailingIssueColumns.length > 0 ? ( { setAssigneePickerIssueId(open ? issue.id : null); if (!open) setAssigneeSearch(""); }} > e.stopPropagation()} onPointerDownOutside={() => setAssigneeSearch("")} > setAssigneeSearch(e.target.value)} autoFocus />
{currentUserId && ( )} {(agents ?? []) .filter((agent) => { if (!assigneeSearch.trim()) return true; return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); }) .map((agent) => ( ))}
)} /> ) : undefined )} /> {hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
); }; return roots.map((issue) => renderIssueRow(issue, 0)); })()}
)) )}
); }