Refine inbox tabs and layout

This commit is contained in:
Dotta
2026-03-11 08:26:41 -05:00
parent 57dcdb51af
commit 96e03b45b9
5 changed files with 56 additions and 41 deletions

View File

@@ -140,8 +140,10 @@ function boardRoutes() {
<Route path="costs" element={<Costs />} /> <Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} /> <Route path="activity" element={<Activity />} />
<Route path="inbox" element={<InboxRootRedirect />} /> <Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/new" element={<Inbox />} /> <Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} /> <Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
<Route path="design-guide" element={<DesignGuide />} /> <Route path="design-guide" element={<DesignGuide />} />
<Route path="*" element={<NotFoundPage scope="board" />} /> <Route path="*" element={<NotFoundPage scope="board" />} />
</> </>

View File

@@ -256,7 +256,7 @@ function buildJoinRequestToast(
title: `${label} wants to join`, title: `${label} wants to join`,
body: "A new join request is waiting for approval.", body: "A new join request is waiting for approval.",
tone: "info", tone: "info",
action: { label: "View inbox", href: "/inbox/new" }, action: { label: "View inbox", href: "/inbox/unread" },
dedupeKey: `join-request:${entityId}`, dedupeKey: `join-request:${entityId}`,
}; };
} }

View File

@@ -220,11 +220,16 @@ describe("inbox helpers", () => {
expect(issues).toHaveLength(2); expect(issues).toHaveLength(2);
}); });
it("defaults the remembered inbox tab to new and persists all", () => { it("defaults the remembered inbox tab to recent and persists all", () => {
localStorage.clear(); localStorage.clear();
expect(loadLastInboxTab()).toBe("new"); expect(loadLastInboxTab()).toBe("recent");
saveLastInboxTab("all"); saveLastInboxTab("all");
expect(loadLastInboxTab()).toBe("all"); expect(loadLastInboxTab()).toBe("all");
}); });
it("maps legacy new-tab storage to recent", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("recent");
});
}); });

View File

@@ -11,7 +11,7 @@ export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
export const DISMISSED_KEY = "paperclip:inbox:dismissed"; export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "new" | "all"; export type InboxTab = "recent" | "unread" | "all";
export interface InboxBadgeData { export interface InboxBadgeData {
inbox: number; inbox: number;
@@ -42,9 +42,11 @@ export function saveDismissedInboxItems(ids: Set<string>) {
export function loadLastInboxTab(): InboxTab { export function loadLastInboxTab(): InboxTab {
try { try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
return raw === "all" ? "all" : "new"; if (raw === "all" || raw === "unread" || raw === "recent") return raw;
if (raw === "new") return "recent";
return "recent";
} catch { } catch {
return "new"; return "recent";
} }
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router"; import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
@@ -44,7 +44,6 @@ import {
ACTIONABLE_APPROVAL_STATUSES, ACTIONABLE_APPROVAL_STATUSES,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
type InboxTab, type InboxTab,
normalizeTimestamp,
RECENT_ISSUES_LIMIT, RECENT_ISSUES_LIMIT,
saveLastInboxTab, saveLastInboxTab,
sortIssuesByMostRecentActivity, sortIssuesByMostRecentActivity,
@@ -245,8 +244,9 @@ export function Inbox() {
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems(); const { dismissed, dismiss } = useDismissedInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "new"; const pathSegment = location.pathname.split("/").pop() ?? "recent";
const tab: InboxTab = pathSegment === "all" ? "all" : "new"; const tab: InboxTab =
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
const issueLinkState = useMemo( const issueLinkState = useMemo(
() => () =>
createIssueDetailLocationState( createIssueDetailLocationState(
@@ -333,6 +333,10 @@ export function Inbox() {
() => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
[touchedIssuesRaw], [touchedIssuesRaw],
); );
const unreadTouchedIssues = useMemo(
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
[touchedIssues],
);
const agentById = useMemo(() => { const agentById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -478,17 +482,22 @@ export function Inbox() {
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals; const approvalsToRender = tab === "unread" ? actionableApprovals : filteredAllApprovals;
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues; const showTouchedSection =
tab === "all"
? showTouchedCategory && hasTouchedIssues
: tab === "unread"
? unreadTouchedIssues.length > 0
: hasTouchedIssues;
const showJoinRequestsSection = const showJoinRequestsSection =
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests; tab === "all" ? showJoinRequestsCategory && hasJoinRequests : hasJoinRequests;
const showApprovalsSection = const showApprovalsSection =
tab === "new" tab === "unread"
? actionableApprovals.length > 0 ? actionableApprovals.length > 0
: showApprovalsCategory && filteredAllApprovals.length > 0; : showApprovalsCategory && filteredAllApprovals.length > 0;
const showFailedRunsSection = const showFailedRunsSection =
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures; tab === "all" ? showFailedRunsCategory && hasRunFailures : hasRunFailures;
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts; const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : hasAlerts;
const visibleSections = [ const visibleSections = [
showFailedRunsSection ? "failed_runs" : null, showFailedRunsSection ? "failed_runs" : null,
@@ -511,13 +520,14 @@ export function Inbox() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value === "all" ? "all" : "new"}`)}> <Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar <PageTabBar
items={[ items={[
{ {
value: "new", value: "recent",
label: "New", label: "Recent",
}, },
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" }, { value: "all", label: "All" },
]} ]}
/> />
@@ -572,9 +582,11 @@ export function Inbox() {
<EmptyState <EmptyState
icon={InboxIcon} icon={InboxIcon}
message={ message={
tab === "new" tab === "unread"
? "No new inbox items." ? "No new inbox items."
: "No inbox items match these filters." : tab === "recent"
? "No recent inbox items."
: "No inbox items match these filters."
} }
/> />
)} )}
@@ -584,7 +596,7 @@ export function Inbox() {
{showSeparatorBefore("approvals") && <Separator />} {showSeparatorBefore("approvals") && <Separator />}
<div> <div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{tab === "new" ? "Approvals Needing Action" : "Approvals"} {tab === "unread" ? "Approvals Needing Action" : "Approvals"}
</h3> </h3>
<div className="grid gap-3"> <div className="grid gap-3">
{approvalsToRender.map((approval) => ( {approvalsToRender.map((approval) => (
@@ -750,7 +762,7 @@ export function Inbox() {
My Recent Issues My Recent Issues
</h3> </h3>
<div className="divide-y divide-border border border-border"> <div className="divide-y divide-border border border-border">
{touchedIssues.map((issue) => { {(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id);
return ( return (
@@ -760,17 +772,18 @@ export function Inbox() {
state={issueLinkState} state={issueLinkState}
className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
> >
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents"> <span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate"> <span className="flex min-w-0 items-start gap-3 sm:order-1">
{issue.title} <span className="line-clamp-2 min-w-0 flex-1 text-sm sm:line-clamp-1 sm:truncate">
{issue.title}
</span>
<span className="hidden shrink-0 text-xs text-muted-foreground sm:block">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</span> </span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0"> <span className="flex items-center gap-2 sm:order-2 sm:shrink-0">
{(isUnread || isFading) ? ( {(isUnread || isFading) ? (
<span <span
role="button" role="button"
@@ -800,18 +813,11 @@ export function Inbox() {
<span className="hidden sm:inline-flex h-4 w-4 shrink-0" /> <span className="hidden sm:inline-flex h-4 w-4 shrink-0" />
)} )}
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span> <span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="inline-flex sm:hidden"><StatusIcon status={issue.status} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span> <span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground"> <span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)} {issue.identifier ?? issue.id.slice(0, 8)}
</span> </span>
<span className="text-xs text-muted-foreground sm:hidden">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:order-last">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</span> </span>
</span> </span>