bc5b30eccf
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>
782 lines
34 KiB
TypeScript
782 lines
34 KiB
TypeScript
import { useEffect, 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 { timeAgo } from "../lib/timeAgo";
|
|
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[];
|
|
projects: 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: [],
|
|
projects: [],
|
|
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)));
|
|
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;
|
|
}
|
|
|
|
interface ProjectOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface IssuesListProps {
|
|
issues: Issue[];
|
|
isLoading?: boolean;
|
|
error?: Error | null;
|
|
agents?: Agent[];
|
|
projects?: ProjectOption[];
|
|
liveIssueIds?: Set<string>;
|
|
projectId?: string;
|
|
viewStateKey: string;
|
|
initialAssignees?: string[];
|
|
initialSearch?: string;
|
|
onSearchChange?: (search: string) => void;
|
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
|
}
|
|
|
|
export function IssuesList({
|
|
issues,
|
|
isLoading,
|
|
error,
|
|
agents,
|
|
projects,
|
|
liveIssueIds,
|
|
projectId,
|
|
viewStateKey,
|
|
initialAssignees,
|
|
initialSearch,
|
|
onSearchChange,
|
|
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<IssueViewState>(() => {
|
|
if (initialAssignees) {
|
|
return { ...defaultViewState, assignees: initialAssignees, statuses: [] };
|
|
}
|
|
return getViewState(scopedKey);
|
|
});
|
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
|
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
|
const [debouncedIssueSearch, setDebouncedIssueSearch] = useState(issueSearch);
|
|
const normalizedIssueSearch = debouncedIssueSearch.trim();
|
|
|
|
useEffect(() => {
|
|
setIssueSearch(initialSearch ?? "");
|
|
}, [initialSearch]);
|
|
|
|
useEffect(() => {
|
|
const timeoutId = window.setTimeout(() => {
|
|
setDebouncedIssueSearch(issueSearch);
|
|
}, 300);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [issueSearch]);
|
|
|
|
// 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<IssueViewState>) => {
|
|
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<string, string> = {};
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
|
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
|
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
|
|
<Plus className="h-4 w-4 sm:mr-1" />
|
|
<span className="hidden sm:inline">New Issue</span>
|
|
</Button>
|
|
<div className="relative w-48 sm:w-64 md:w-80">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={issueSearch}
|
|
onChange={(e) => {
|
|
setIssueSearch(e.target.value);
|
|
onSearchChange?.(e.target.value);
|
|
}}
|
|
placeholder="Search issues..."
|
|
className="pl-7 text-xs sm:text-sm"
|
|
aria-label="Search issues"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
|
{/* View mode toggle */}
|
|
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1">
|
|
<button
|
|
className={`p-1.5 transition-colors ${viewState.viewMode === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
|
onClick={() => updateView({ viewMode: "list" })}
|
|
title="List view"
|
|
>
|
|
<List className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
className={`p-1.5 transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
|
onClick={() => updateView({ viewMode: "board" })}
|
|
title="Board view"
|
|
>
|
|
<Columns3 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}>
|
|
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
|
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
|
|
{activeFilterCount > 0 && (
|
|
<span className="sm:hidden text-[10px] font-medium ml-0.5">{activeFilterCount}</span>
|
|
)}
|
|
{activeFilterCount > 0 && (
|
|
<X
|
|
className="h-3 w-3 ml-1 hidden sm:block"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
updateView({ statuses: [], priorities: [], assignees: [], labels: [], projects: [] });
|
|
}}
|
|
/>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-[min(480px,calc(100vw-2rem))] p-0">
|
|
<div className="p-3 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Filters</span>
|
|
{activeFilterCount > 0 && (
|
|
<button
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => updateView({ statuses: [], priorities: [], assignees: [], labels: [] })}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick filters */}
|
|
<div className="space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">Quick filters</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{quickFilterPresets.map((preset) => {
|
|
const isActive = arraysEqual(viewState.statuses, preset.statuses);
|
|
return (
|
|
<button
|
|
key={preset.label}
|
|
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
|
isActive
|
|
? "bg-primary text-primary-foreground border-primary"
|
|
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
|
|
}`}
|
|
onClick={() => updateView({ statuses: isActive ? [] : [...preset.statuses] })}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Multi-column filter sections */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
|
|
{/* Status */}
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-muted-foreground">Status</span>
|
|
<div className="space-y-0.5">
|
|
{statusOrder.map((s) => (
|
|
<label key={s} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
|
<Checkbox
|
|
checked={viewState.statuses.includes(s)}
|
|
onCheckedChange={() => updateView({ statuses: toggleInArray(viewState.statuses, s) })}
|
|
/>
|
|
<StatusIcon status={s} />
|
|
<span className="text-sm">{statusLabel(s)}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Priority + Assignee stacked in right column */}
|
|
<div className="space-y-3">
|
|
{/* Priority */}
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-muted-foreground">Priority</span>
|
|
<div className="space-y-0.5">
|
|
{priorityOrder.map((p) => (
|
|
<label key={p} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
|
<Checkbox
|
|
checked={viewState.priorities.includes(p)}
|
|
onCheckedChange={() => updateView({ priorities: toggleInArray(viewState.priorities, p) })}
|
|
/>
|
|
<PriorityIcon priority={p} />
|
|
<span className="text-sm">{statusLabel(p)}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Assignee */}
|
|
{agents && agents.length > 0 && (
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-muted-foreground">Assignee</span>
|
|
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
{agents.map((agent) => (
|
|
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
|
<Checkbox
|
|
checked={viewState.assignees.includes(agent.id)}
|
|
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
|
/>
|
|
<span className="text-sm">{agent.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{labels && labels.length > 0 && (
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-muted-foreground">Labels</span>
|
|
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
{labels.map((label) => (
|
|
<label key={label.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
|
<Checkbox
|
|
checked={viewState.labels.includes(label.id)}
|
|
onCheckedChange={() => updateView({ labels: toggleInArray(viewState.labels, label.id) })}
|
|
/>
|
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: label.color }} />
|
|
<span className="text-sm">{label.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{projects && projects.length > 0 && (
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-muted-foreground">Project</span>
|
|
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
{projects.map((project) => (
|
|
<label key={project.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
|
<Checkbox
|
|
checked={viewState.projects.includes(project.id)}
|
|
onCheckedChange={() => updateView({ projects: toggleInArray(viewState.projects, project.id) })}
|
|
/>
|
|
<span className="text-sm">{project.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Sort (list view only) */}
|
|
{viewState.viewMode === "list" && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="text-xs">
|
|
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
|
<span className="hidden sm:inline">Sort</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-48 p-0">
|
|
<div className="p-2 space-y-0.5">
|
|
{([
|
|
["status", "Status"],
|
|
["priority", "Priority"],
|
|
["title", "Title"],
|
|
["created", "Created"],
|
|
["updated", "Updated"],
|
|
] as const).map(([field, label]) => (
|
|
<button
|
|
key={field}
|
|
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
|
|
viewState.sortField === field ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
|
|
}`}
|
|
onClick={() => {
|
|
if (viewState.sortField === field) {
|
|
updateView({ sortDir: viewState.sortDir === "asc" ? "desc" : "asc" });
|
|
} else {
|
|
updateView({ sortField: field, sortDir: "asc" });
|
|
}
|
|
}}
|
|
>
|
|
<span>{label}</span>
|
|
{viewState.sortField === field && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{viewState.sortDir === "asc" ? "\u2191" : "\u2193"}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
|
|
{/* Group (list view only) */}
|
|
{viewState.viewMode === "list" && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="text-xs">
|
|
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
|
<span className="hidden sm:inline">Group</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-44 p-0">
|
|
<div className="p-2 space-y-0.5">
|
|
{([
|
|
["status", "Status"],
|
|
["priority", "Priority"],
|
|
["assignee", "Assignee"],
|
|
["none", "None"],
|
|
] as const).map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
|
|
viewState.groupBy === value ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
|
|
}`}
|
|
onClick={() => updateView({ groupBy: value })}
|
|
>
|
|
<span>{label}</span>
|
|
{viewState.groupBy === value && <Check className="h-3.5 w-3.5" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading && <PageSkeleton variant="issues-list" />}
|
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
|
|
|
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
|
<EmptyState
|
|
icon={CircleDot}
|
|
message="No issues match the current filters or search."
|
|
action="Create Issue"
|
|
onAction={() => openNewIssue(newIssueDefaults())}
|
|
/>
|
|
)}
|
|
|
|
{viewState.viewMode === "board" ? (
|
|
<KanbanBoard
|
|
issues={filtered}
|
|
agents={agents}
|
|
liveIssueIds={liveIssueIds}
|
|
onUpdateIssue={onUpdateIssue}
|
|
/>
|
|
) : (
|
|
groupedContent.map((group) => (
|
|
<Collapsible
|
|
key={group.key}
|
|
open={!viewState.collapsedGroups.includes(group.key)}
|
|
onOpenChange={(open) => {
|
|
updateView({
|
|
collapsedGroups: open
|
|
? viewState.collapsedGroups.filter((k) => k !== group.key)
|
|
: [...viewState.collapsedGroups, group.key],
|
|
});
|
|
}}
|
|
>
|
|
{group.label && (
|
|
<div className="flex items-center py-1.5 pl-1 pr-3">
|
|
<CollapsibleTrigger className="flex items-center gap-1.5">
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
|
<span className="text-sm font-semibold uppercase tracking-wide">
|
|
{group.label}
|
|
</span>
|
|
</CollapsibleTrigger>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="ml-auto text-muted-foreground"
|
|
onClick={() => openNewIssue(newIssueDefaults(group.key))}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<CollapsibleContent>
|
|
{group.items.map((issue) => (
|
|
<Link
|
|
key={issue.id}
|
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
|
|
>
|
|
{/* Status icon - left column on mobile, inline on desktop */}
|
|
<span className="shrink-0 pt-px sm:hidden" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
<StatusIcon
|
|
status={issue.status}
|
|
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
|
/>
|
|
</span>
|
|
|
|
{/* Right column on mobile: title + metadata stacked */}
|
|
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
|
{/* Title line */}
|
|
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
|
|
{issue.title}
|
|
</span>
|
|
|
|
{/* Metadata line */}
|
|
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
|
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
|
|
<span className="w-3.5 shrink-0 hidden sm:block" />
|
|
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
|
|
<span className="hidden shrink-0 sm:inline-flex" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
<StatusIcon
|
|
status={issue.status}
|
|
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
|
/>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</span>
|
|
{liveIssueIds?.has(issue.id) && (
|
|
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
|
</span>
|
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-muted-foreground sm:hidden">·</span>
|
|
<span className="text-xs text-muted-foreground sm:hidden">
|
|
{timeAgo(issue.updatedAt)}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
|
|
{/* Desktop-only trailing content */}
|
|
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
|
{(issue.labels ?? []).length > 0 && (
|
|
<span className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
|
|
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
|
<span
|
|
key={label.id}
|
|
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
|
style={{
|
|
borderColor: label.color,
|
|
color: label.color,
|
|
backgroundColor: `${label.color}1f`,
|
|
}}
|
|
>
|
|
{label.name}
|
|
</span>
|
|
))}
|
|
{(issue.labels ?? []).length > 3 && (
|
|
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
<Popover
|
|
open={assigneePickerIssueId === issue.id}
|
|
onOpenChange={(open) => {
|
|
setAssigneePickerIssueId(open ? issue.id : null);
|
|
if (!open) setAssigneeSearch("");
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
|
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
|
<User className="h-3 w-3" />
|
|
</span>
|
|
Assignee
|
|
</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-56 p-1"
|
|
align="end"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onPointerDownOutside={() => setAssigneeSearch("")}
|
|
>
|
|
<input
|
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
|
placeholder="Search agents..."
|
|
value={assigneeSearch}
|
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
!issue.assigneeAgentId && "bg-accent"
|
|
)}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
assignIssue(issue.id, null);
|
|
}}
|
|
>
|
|
No assignee
|
|
</button>
|
|
{(agents ?? [])
|
|
.filter((agent) => {
|
|
if (!assigneeSearch.trim()) return true;
|
|
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
|
})
|
|
.map((agent) => (
|
|
<button
|
|
key={agent.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
|
issue.assigneeAgentId === agent.id && "bg-accent"
|
|
)}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
assignIssue(issue.id, agent.id);
|
|
}}
|
|
>
|
|
<Identity name={agent.name} size="sm" className="min-w-0" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDate(issue.createdAt)}
|
|
</span>
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|