The live badge was positioned after the assignee column, pushing the date column out of alignment on rows with live agents. Move it before the assignee so it doesn't displace the layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
708 lines
30 KiB
TypeScript
708 lines
30 KiB
TypeScript
import { useMemo, useState, useCallback } from "react";
|
|
import { Link } from "react-router-dom";
|
|
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 { 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 "@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[];
|
|
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: ["todo", "in_progress", "in_review", "blocked"],
|
|
priorities: [],
|
|
assignees: [],
|
|
labels: [],
|
|
sortField: "status",
|
|
sortDir: "asc",
|
|
groupBy: "status",
|
|
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 applySearch(issues: Issue[], searchQuery: string, agentName: (id: string | null) => string | null): Issue[] {
|
|
const query = searchQuery.trim().toLowerCase();
|
|
if (!query) return issues;
|
|
|
|
return issues.filter((issue) => {
|
|
const fields = [
|
|
issue.identifier ?? "",
|
|
issue.title,
|
|
issue.description ?? "",
|
|
issue.status,
|
|
issue.priority,
|
|
agentName(issue.assigneeAgentId) ?? "",
|
|
...(issue.labels ?? []).map((label) => label.name),
|
|
];
|
|
|
|
return fields.some((field) => field.toLowerCase().includes(query));
|
|
});
|
|
}
|
|
|
|
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<string>;
|
|
projectId?: string;
|
|
viewStateKey: string;
|
|
initialAssignees?: string[];
|
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
|
}
|
|
|
|
export function IssuesList({
|
|
issues,
|
|
isLoading,
|
|
error,
|
|
agents,
|
|
liveIssueIds,
|
|
projectId,
|
|
viewStateKey,
|
|
initialAssignees,
|
|
onUpdateIssue,
|
|
}: IssuesListProps) {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { openNewIssue } = useDialog();
|
|
|
|
const [viewState, setViewState] = useState<IssueViewState>(() => {
|
|
if (initialAssignees) {
|
|
return { ...defaultViewState, assignees: initialAssignees, statuses: [] };
|
|
}
|
|
return getViewState(viewStateKey);
|
|
});
|
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
|
const [issueSearch, setIssueSearch] = useState("");
|
|
|
|
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
|
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(() => {
|
|
const filteredByControls = applyFilters(issues, viewState);
|
|
const filteredBySearch = applySearch(filteredByControls, issueSearch, agentName);
|
|
return sortIssues(filteredBySearch, viewState);
|
|
}, [issues, viewState, issueSearch, agents]);
|
|
|
|
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-36 sm:w-52 md:w-64">
|
|
<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)}
|
|
placeholder="Search issues..."
|
|
className="h-8 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: [] });
|
|
}}
|
|
/>
|
|
)}
|
|
</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>
|
|
)}
|
|
</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 && <p className="text-sm text-muted-foreground">Loading...</p>}
|
|
{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-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
|
|
>
|
|
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
|
|
<div className="w-3.5 shrink-0 hidden sm:block" />
|
|
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
<StatusIcon
|
|
status={issue.status}
|
|
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
|
/>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground font-mono shrink-0">
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</span>
|
|
<span className="truncate flex-1 min-w-0">{issue.title}</span>
|
|
{(issue.labels ?? []).length > 0 && (
|
|
<div 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>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
|
{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>
|
|
)}
|
|
<div className="hidden sm:block">
|
|
<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>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground hidden sm:inline">
|
|
{formatDate(issue.createdAt)}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|