From 2a4c0c312d813a186e2786454f18406349ec36ea Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 23 Feb 2026 09:55:28 -0600 Subject: [PATCH] style: make issue filters bar smaller and tighter - Reduce filter/sort/group button text to text-xs - Shrink icons from h-3.5/w-3.5 to h-3/w-3 - Tighten icon-to-text spacing from mr-1.5 to mr-1 Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 482 +++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 ui/src/components/IssuesList.tsx diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx new file mode 100644 index 00000000..bd0c0333 --- /dev/null +++ b/ui/src/components/IssuesList.tsx @@ -0,0 +1,482 @@ +import { useMemo, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useDialog } from "../context/DialogContext"; +import { groupBy } from "../lib/groupBy"; +import { formatDate } from "../lib/utils"; +import { StatusIcon } from "./StatusIcon"; +import { PriorityIcon } from "./PriorityIcon"; +import { EmptyState } from "./EmptyState"; +import { Identity } from "./Identity"; +import { Button } from "@/components/ui/button"; +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 } from "lucide-react"; +import type { Issue } from "@paperclip/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[]; + sortField: "status" | "priority" | "title" | "created" | "updated"; + sortDir: "asc" | "desc"; + groupBy: "status" | "priority" | "assignee" | "none"; +}; + +const defaultViewState: IssueViewState = { + statuses: ["todo", "in_progress", "in_review", "blocked"], + priorities: [], + assignees: [], + sortField: "status", + sortDir: "asc", + groupBy: "status", +}; + +const quickFilterPresets = [ + { 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)); + 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++; + 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; + onUpdateIssue: (id: string, data: Record) => void; +} + +export function IssuesList({ + issues, + isLoading, + error, + agents, + liveIssueIds, + projectId, + viewStateKey, + onUpdateIssue, +}: IssuesListProps) { + const { openNewIssue } = useDialog(); + const navigate = useNavigate(); + + const [viewState, setViewState] = useState(() => getViewState(viewStateKey)); + + const updateView = useCallback((patch: Partial) => { + setViewState((prev) => { + const next = { ...prev, ...patch }; + saveViewState(viewStateKey, next); + return next; + }); + }, [viewStateKey]); + + const agentName = (id: string | null) => { + if (!id || !agents) return null; + return agents.find((a) => a.id === id)?.name ?? null; + }; + + const filtered = useMemo(() => { + return sortIssues(applyFilters(issues, viewState), viewState); + }, [issues, viewState]); + + 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; + }; + + return ( +
+ {/* Toolbar */} +
+ + +
+ {/* Filter */} + + + + + +
+
+ Filters + {activeFilterCount > 0 && ( + + )} +
+ + {/* Quick filters */} +
+ Quick filters +
+ {quickFilterPresets.map((preset) => { + const isActive = arraysEqual(viewState.statuses, preset.statuses); + return ( + + ); + })} +
+
+ +
+ + {/* Status */} +
+ Status +
+ {statusOrder.map((s) => ( + + ))} +
+
+ +
+ + {/* Priority */} +
+ Priority +
+ {priorityOrder.map((p) => ( + + ))} +
+
+ + {/* Assignee */} + {agents && agents.length > 0 && ( + <> +
+
+ Assignee +
+ {agents.map((agent) => ( + + ))} +
+
+ + )} +
+ + + + {/* Sort */} + + + + + +
+ {([ + ["status", "Status"], + ["priority", "Priority"], + ["title", "Title"], + ["created", "Created"], + ["updated", "Updated"], + ] as const).map(([field, label]) => ( + + ))} +
+
+
+ + {/* Group */} + + + + + +
+ {([ + ["status", "Status"], + ["priority", "Priority"], + ["assignee", "Assignee"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
+
+
+ + {isLoading &&

Loading...

} + {error &&

{error.message}

} + + {!isLoading && filtered.length === 0 && ( + openNewIssue(newIssueDefaults())} + /> + )} + + {groupedContent.map((group) => ( + + {group.label && ( +
+ + + + {group.label} + + + +
+ )} + + {group.items.map((issue) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
navigate(`/issues/${issue.identifier ?? issue.id}`)} + > + {/* Spacer matching caret width so status icon aligns with group title */} +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()}> + onUpdateIssue(issue.id, { status: s })} + /> +
+ + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} +
+ {liveIssueIds?.has(issue.id) && ( + + + + + + Live + + )} + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : {issue.assigneeAgentId.slice(0, 8)}; + })()} + + {formatDate(issue.createdAt)} + +
+
+ ))} + + + ))} +
+ ); +}