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 { shouldBlurPageSearchOnEnter, shouldBlurPageSearchOnEscape, } from "../lib/keyboardShortcuts"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, } from "../lib/issue-filters"; import { DEFAULT_INBOX_ISSUE_COLUMNS, getAvailableInboxIssueColumns, normalizeInboxIssueColumns, resolveIssueWorkspaceName, type InboxIssueColumn, } from "../lib/inbox"; import { cn } from "../lib/utils"; import { InboxIssueMetaLeading, InboxIssueTrailingColumns, IssueColumnPicker, issueActivityText, issueTrailingColumns, } from "./IssueColumns"; import { StatusIcon } from "./StatusIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; import { IssueGroupHeader } from "./IssueGroupHeader"; import { IssueFiltersPopover } from "./IssueFiltersPopover"; 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 { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import type { Issue, Project } from "@paperclipai/shared"; const ISSUE_SEARCH_DEBOUNCE_MS = 150; /* ── View state ── */ export type IssueViewState = IssueFilterState & { sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; viewMode: "list" | "board"; nestingEnabled: boolean; collapsedGroups: string[]; collapsedParents: string[]; }; const defaultViewState: IssueViewState = { ...defaultIssueFilterState, sortField: "updated", sortDir: "desc", groupBy: "none", viewMode: "list", nestingEnabled: true, collapsedGroups: [], collapsedParents: [], }; 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 getInitialViewState(key: string, initialAssignees?: string[]): IssueViewState { const stored = getViewState(key); if (!initialAssignees) return stored; return { ...stored, assignees: initialAssignees, statuses: [], }; } function getIssueColumnsStorageKey(key: string): string { return `${key}:issue-columns`; } function loadIssueColumns(key: string): InboxIssueColumn[] { try { const raw = localStorage.getItem(getIssueColumnsStorageKey(key)); if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS; return normalizeInboxIssueColumns(parsed); } catch { return DEFAULT_INBOX_ISSUE_COLUMNS; } } function saveIssueColumns(key: string, columns: InboxIssueColumn[]) { try { localStorage.setItem( getIssueColumnsStorageKey(key), JSON.stringify(normalizeInboxIssueColumns(columns)), ); } catch { // Ignore localStorage failures. } } 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 * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status)); case "priority": return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.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; } /* ── Component ── */ interface Agent { id: string; name: string; } type ProjectOption = Pick & Partial>; type IssueListRequestFilters = NonNullable[1]>; 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?: Omit; baseCreateIssueDefaults?: Record; createIssueLabel?: string; enableRoutineVisibilityFilter?: boolean; 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); }} onKeyDown={(e) => { if (shouldBlurPageSearchOnEnter({ key: e.key, isComposing: e.nativeEvent.isComposing, })) { e.currentTarget.blur(); return; } if (shouldBlurPageSearchOnEscape({ key: e.key, isComposing: e.nativeEvent.isComposing, currentValue: e.currentTarget.value, })) { e.currentTarget.blur(); } }} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" data-page-search-target="true" />
); } export function IssuesList({ issues, isLoading, error, agents, projects, liveIssueIds, projectId, viewStateKey, issueLinkState, initialAssignees, initialSearch, searchFilters, baseCreateIssueDefaults, createIssueLabel, enableRoutineVisibilityFilter = false, 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 initialAssigneesKey = initialAssignees?.join("|") ?? ""; const [viewState, setViewState] = useState(() => getInitialViewState(scopedKey, initialAssignees)); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const [visibleIssueColumns, setVisibleIssueColumns] = useState(() => loadIssueColumns(scopedKey)); const deferredIssueSearch = useDeferredValue(issueSearch); const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); useEffect(() => { setIssueSearch(initialSearch ?? ""); }, [initialSearch]); // Reload view state whenever the persisted context changes. const prevViewStateContextKey = useRef(`${scopedKey}::${initialAssigneesKey}`); useEffect(() => { const nextContextKey = `${scopedKey}::${initialAssigneesKey}`; if (prevViewStateContextKey.current !== nextContextKey) { prevViewStateContextKey.current = nextContextKey; setViewState(getInitialViewState(scopedKey, initialAssignees)); } }, [scopedKey, initialAssignees, initialAssigneesKey]); const prevColumnsScopedKey = useRef(scopedKey); useEffect(() => { if (prevColumnsScopedKey.current !== scopedKey) { prevColumnsScopedKey.current = scopedKey; setVisibleIssueColumns(loadIssueColumns(scopedKey)); } }, [scopedKey]); 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 ?? {}, enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters, ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), }), 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 workspaceOptions = useMemo(() => { const options = new Map(); for (const [workspaceId, workspaceName] of workspaceNameMap) { options.set(workspaceId, workspaceName); } return [...options.entries()] .sort((a, b) => a[1].localeCompare(b[1])) .map(([id, name]) => ({ id, name })); }, [workspaceNameMap]); 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 = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter); return sortIssues(filteredByControls, viewState); }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), queryFn: () => issuesApi.listLabels(selectedCompanyId!), enabled: !!selectedCompanyId, }); const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); 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 issueStatusOrder .filter((s) => groups[s]?.length) .map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! })); } if (viewState.groupBy === "priority") { const groups = groupBy(filtered, (i) => i.priority); return issuePriorityOrder .filter((p) => groups[p]?.length) .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__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 = { ...(baseCreateIssueDefaults ?? {}) }; if (projectId && defaults.projectId === undefined) 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") { const parentIssue = issueById.get(groupKey); if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId)); else defaults.parentId = groupKey; } } return defaults; }, [baseCreateIssueDefaults, currentUserId, issueById, projectId, viewState.groupBy]); const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Issue"; const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Issue"; const openCreateIssueDialog = useCallback((groupKey?: string) => { openNewIssue(newIssueDefaults(groupKey)); }, [newIssueDefaults, openNewIssue]); const filterToWorkspace = useCallback((workspaceId: string) => { updateView({ workspaces: [workspaceId] }); }, [updateView]); const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); saveIssueColumns(scopedKey, normalized); }, [scopedKey]); 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 */}
{viewState.viewMode === "list" && ( )} setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which issue columns stay visible" iconOnly /> ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} iconOnly workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} /> {/* 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" && ( openCreateIssueDialog()} /> )} {viewState.viewMode === "board" ? ( ) : ( groupedContent.map((group) => ( { updateView({ collapsedGroups: open ? viewState.collapsedGroups.filter((k) => k !== group.key) : [...viewState.collapsedGroups, group.key], }); }} > {group.label && ( { updateView({ collapsedGroups: viewState.collapsedGroups.includes(group.key) ? viewState.collapsedGroups.filter((k) => k !== group.key) : [...viewState.collapsedGroups, group.key], }); }} trailing={( )} /> )} {(() => { const { roots, childMap } = viewState.nestingEnabled ? buildIssueTree(group.items) : { roots: group.items, childMap: new Map() }; 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)); })()}
)) )}
); }