Reuse inbox issue column controls in issues lists

This commit is contained in:
dotta 2026-04-07 16:45:57 -05:00
parent 1cbb0a5e34
commit ee82a4f243
5 changed files with 680 additions and 449 deletions

View file

@ -26,17 +26,21 @@ import {
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import {
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
IssueColumnPicker,
issueActivityText,
issueTrailingColumns,
} from "../components/IssueColumns";
import { IssueRow } from "../components/IssueRow";
import { SwipeToArchive } from "../components/SwipeToArchive";
import { StatusIcon } from "../components/StatusIcon";
import { cn } from "../lib/utils";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { timeAgo } from "../lib/timeAgo";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -48,15 +52,6 @@ import {
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
@ -71,7 +66,6 @@ import {
X,
RotateCcw,
UserPlus,
Columns3,
Search,
} from "lucide-react";
import { Input } from "@/components/ui/input";
@ -101,6 +95,8 @@ import {
} from "../lib/inbox";
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
@ -141,245 +137,6 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
status: "Status",
id: "ID",
assignee: "Assignee",
project: "Project",
workspace: "Workspace",
parent: "Parent issue",
labels: "Tags",
updated: "Last updated",
};
const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
status: "Issue state chip on the left edge.",
id: "Ticket identifier like PAP-1009.",
assignee: "Assigned agent or board user.",
project: "Linked project pill with its color.",
workspace: "Execution or project workspace used for the issue.",
parent: "Parent issue identifier and title.",
labels: "Issue labels and tags.",
updated: "Latest visible activity time.",
};
export function InboxIssueMetaLeading({
issue,
isLive,
showStatus = true,
showIdentifier = true,
}: {
issue: Issue;
isLive: boolean;
showStatus?: boolean;
showIdentifier?: boolean;
}) {
return (
<>
{showStatus ? (
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
) : null}
{showIdentifier ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
) : null}
{isLive && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
"bg-blue-500/10",
)}
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
"bg-blue-500",
)}
/>
</span>
<span
className={cn(
"hidden text-[11px] font-medium sm:inline",
"text-blue-600 dark:text-blue-400",
)}
>
Live
</span>
</span>
)}
</>
);
}
function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
}
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
return columns
.map((column) => {
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
if (column === "project") return "minmax(6.5rem, 8.5rem)";
if (column === "workspace") return "minmax(9rem, 12rem)";
if (column === "parent") return "minmax(5rem, 7rem)";
if (column === "labels") return "minmax(8rem, 10rem)";
return "minmax(4rem, 5.5rem)";
})
.join(" ");
}
export function InboxIssueTrailingColumns({
issue,
columns,
projectName,
projectColor,
workspaceName,
assigneeName,
currentUserId,
parentIdentifier,
parentTitle,
}: {
issue: Issue;
columns: InboxIssueColumn[];
projectName: string | null;
projectColor: string | null;
workspaceName: string | null;
assigneeName: string | null;
currentUserId: string | null;
parentIdentifier: string | null;
parentTitle: string | null;
}) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
return (
<span
className="grid items-center gap-2"
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
>
{columns.map((column) => {
if (column === "assignee") {
if (issue.assigneeAgentId) {
return (
<span key={column} className="min-w-0 text-xs text-foreground">
<Identity
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
size="sm"
className="min-w-0"
/>
</span>
);
}
if (issue.assigneeUserId) {
return (
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{userLabel}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
Unassigned
</span>
);
}
if (column === "project") {
if (projectName) {
const accentColor = projectColor ?? "#64748b";
return (
<span
key={column}
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span className="truncate">{projectName}</span>
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
No project
</span>
);
}
if (column === "labels") {
if ((issue.labels ?? []).length > 0) {
return (
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
{(issue.labels ?? []).slice(0, 2).map((label) => (
<span
key={label.id}
className="inline-flex min-w-0 max-w-full items-center font-medium"
style={{
color: pickTextColorForPillBg(label.color, 0.12),
}}
>
<span className="truncate">{label.name}</span>
</span>
))}
{(issue.labels ?? []).length > 2 ? (
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
+{(issue.labels ?? []).length - 2}
</span>
) : null}
</span>
);
}
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
if (column === "workspace") {
if (!workspaceName) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
{workspaceName}
</span>
);
}
if (column === "parent") {
if (!issue.parentId) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
{parentIdentifier ? (
<span className="font-mono">{parentIdentifier}</span>
) : (
<span className="italic">Sub-issue</span>
)}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
{activityText}
</span>
);
})}
</span>
);
}
export function FailedRunInboxRow({
run,
@ -1027,7 +784,7 @@ export function Inbox() {
);
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
const visibleTrailingIssueColumns = useMemo(
() => trailingIssueColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
[availableIssueColumnSet, visibleIssueColumnSet],
);
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
@ -1655,58 +1412,13 @@ export function Inbox() {
className="h-8 w-[220px] pl-8 text-xs"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground sm:inline-flex"
>
<Columns3 className="mr-1 h-3.5 w-3.5" />
Show / hide columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Desktop issue rows
</div>
<div className="text-sm font-medium text-foreground">
Choose which inbox columns stay visible
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableIssueColumns.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={visibleIssueColumnSet.has(column)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => toggleIssueColumn(column, checked === true)}
className="items-start rounded-lg px-3 py-2.5 pl-8"
>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
{inboxIssueColumnLabels[column]}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{inboxIssueColumnDescriptions[column]}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
className="rounded-lg px-3 py-2 text-sm"
>
Reset defaults
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
/>
{canMarkAllRead && (
<>
<Button