fix(ui): move live badge to left of assignee in issues list
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>
This commit is contained in:
@@ -12,10 +12,11 @@ import { PriorityIcon } from "./PriorityIcon";
|
|||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User } from "lucide-react";
|
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||||
import { KanbanBoard } from "./KanbanBoard";
|
import { KanbanBoard } from "./KanbanBoard";
|
||||||
import type { Issue } from "@paperclip/shared";
|
import type { Issue } from "@paperclip/shared";
|
||||||
|
|
||||||
@@ -93,6 +94,25 @@ function applyFilters(issues: Issue[], state: IssueViewState): Issue[] {
|
|||||||
return result;
|
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[] {
|
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
||||||
const sorted = [...issues];
|
const sorted = [...issues];
|
||||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||||
@@ -165,6 +185,7 @@ export function IssuesList({
|
|||||||
});
|
});
|
||||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||||
|
const [issueSearch, setIssueSearch] = useState("");
|
||||||
|
|
||||||
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
||||||
setViewState((prev) => {
|
setViewState((prev) => {
|
||||||
@@ -180,8 +201,10 @@ export function IssuesList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return sortIssues(applyFilters(issues, viewState), viewState);
|
const filteredByControls = applyFilters(issues, viewState);
|
||||||
}, [issues, viewState]);
|
const filteredBySearch = applySearch(filteredByControls, issueSearch, agentName);
|
||||||
|
return sortIssues(filteredBySearch, viewState);
|
||||||
|
}, [issues, viewState, issueSearch, agents]);
|
||||||
|
|
||||||
const { data: labels } = useQuery({
|
const { data: labels } = useQuery({
|
||||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||||
@@ -237,10 +260,22 @@ export function IssuesList({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
||||||
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
|
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
<Plus className="h-4 w-4 sm:mr-1" />
|
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
|
||||||
<span className="hidden sm:inline">New Issue</span>
|
<Plus className="h-4 w-4 sm:mr-1" />
|
||||||
</Button>
|
<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">
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||||
{/* View mode toggle */}
|
{/* View mode toggle */}
|
||||||
@@ -264,7 +299,7 @@ export function IssuesList({
|
|||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-400" : ""}`}>
|
<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" />
|
<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>
|
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
@@ -484,7 +519,7 @@ export function IssuesList({
|
|||||||
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={CircleDot}
|
icon={CircleDot}
|
||||||
message="No issues match the current filters."
|
message="No issues match the current filters or search."
|
||||||
action="Create Issue"
|
action="Create Issue"
|
||||||
onAction={() => openNewIssue(newIssueDefaults())}
|
onAction={() => openNewIssue(newIssueDefaults())}
|
||||||
/>
|
/>
|
||||||
@@ -568,6 +603,15 @@ export function IssuesList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
<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">
|
<div className="hidden sm:block">
|
||||||
<Popover
|
<Popover
|
||||||
open={assigneePickerIssueId === issue.id}
|
open={assigneePickerIssueId === issue.id}
|
||||||
@@ -648,15 +692,6 @@ export function IssuesList({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{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-400 hidden sm:inline">Live</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||||
{formatDate(issue.createdAt)}
|
{formatDate(issue.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user