From 3b9da0ee95c264aa288aec4d891a3baac93f4792 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 20:51:28 -0500 Subject: [PATCH] Refactor shared issue rows Co-Authored-By: Paperclip --- ui/src/components/IssueRow.tsx | 112 +++++++++++++ ui/src/components/IssuesList.tsx | 270 +++++++++++++++---------------- ui/src/pages/Inbox.tsx | 67 ++------ 3 files changed, 249 insertions(+), 200 deletions(-) create mode 100644 ui/src/components/IssueRow.tsx diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx new file mode 100644 index 00000000..67e4f654 --- /dev/null +++ b/ui/src/components/IssueRow.tsx @@ -0,0 +1,112 @@ +import type { ReactNode } from "react"; +import type { Issue } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { cn } from "../lib/utils"; +import { PriorityIcon } from "./PriorityIcon"; +import { StatusIcon } from "./StatusIcon"; + +type UnreadState = "hidden" | "visible" | "fading"; + +interface IssueRowProps { + issue: Issue; + issueLinkState?: unknown; + statusControl?: ReactNode; + mobileMeta?: ReactNode; + trailingContent?: ReactNode; + trailingMeta?: ReactNode; + unreadState?: UnreadState | null; + onMarkRead?: () => void; + className?: string; +} + +export function IssueRow({ + issue, + issueLinkState, + statusControl, + mobileMeta, + trailingContent, + trailingMeta, + unreadState = null, + onMarkRead, + className, +}: IssueRowProps) { + const issuePathId = issue.identifier ?? issue.id; + const identifier = issue.identifier ?? issue.id.slice(0, 8); + const showUnreadSlot = unreadState !== null; + const showUnreadDot = unreadState === "visible" || unreadState === "fading"; + + return ( + + + + + + {statusControl ?? } + + + {identifier} + + + + {issue.title} + + + {identifier} + {mobileMeta ? ( + <> + + {mobileMeta} + + ) : null} + + + {trailingContent ? ( + {trailingContent} + ) : null} + {trailingMeta ? ( + + {trailingMeta} + + ) : null} + {showUnreadSlot ? ( + + {showUnreadDot ? ( + + ) : ( + + ) : null} + + ); +} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index da1c161d..e28e2fb5 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,5 +1,4 @@ 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"; @@ -12,6 +11,7 @@ import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; +import { IssueRow } from "./IssueRow"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -590,162 +590,144 @@ export function IssuesList({ )} {group.items.map((issue) => ( - - {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> - - - {/* Right column on mobile: title + metadata stacked */} - - {/* Title line */} - - {issue.title} - - - {/* Metadata line */} - - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} - - - { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - Live - - )} - · - - {timeAgo(issue.updatedAt)} - - - - - {/* Desktop-only trailing content */} - - {(issue.labels ?? []).length > 0 && ( - - {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - +{(issue.labels ?? []).length - 3} - )} - - )} - { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); + issue={issue} + issueLinkState={issueLinkState} + className="border-b border-border last:border-b-0 sm:px-3" + statusControl={( + { + e.preventDefault(); + e.stopPropagation(); }} > - - - - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} + + )} + {liveIssueIds?.has(issue.id) && ( + + + + + + + Live + + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
+ - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
- -
- - {formatDate(issue.createdAt)} - -
- + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} + > + setAssigneeSearch(e.target.value)} + autoFocus + /> +
+ + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name + .toLowerCase() + .includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+
+
+ + )} + trailingMeta={formatDate(issue.createdAt)} + /> ))}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ec818c0a..8abafab8 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -12,11 +12,10 @@ import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; -import { StatusIcon } from "../components/StatusIcon"; -import { PriorityIcon } from "../components/PriorityIcon"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { ApprovalCard } from "../components/ApprovalCard"; +import { IssueRow } from "../components/IssueRow"; import { StatusBadge } from "../components/StatusBadge"; import { timeAgo } from "../lib/timeAgo"; import { Button } from "@/components/ui/button"; @@ -805,62 +804,18 @@ export function Inbox() { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( - - - {(isUnread || isFading) ? ( - { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - } - }} - className="inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" - aria-label="Mark as read" - > - - - ) : ( - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - - {issue.title} - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - - {issue.lastExternalCommentAt + issue={issue} + issueLinkState={issueLinkState} + unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} + onMarkRead={() => markReadMutation.mutate(issue.id)} + trailingMeta={ + issue.lastExternalCommentAt ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - - + : `updated ${timeAgo(issue.updatedAt)}` + } + /> ); })}