Refine inbox tabs and layout
This commit is contained in:
@@ -140,8 +140,10 @@ function boardRoutes() {
|
||||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<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/new" element={<Navigate to="/inbox/recent" replace />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||
</>
|
||||
|
||||
@@ -256,7 +256,7 @@ function buildJoinRequestToast(
|
||||
title: `${label} wants to join`,
|
||||
body: "A new join request is waiting for approval.",
|
||||
tone: "info",
|
||||
action: { label: "View inbox", href: "/inbox/new" },
|
||||
action: { label: "View inbox", href: "/inbox/unread" },
|
||||
dedupeKey: `join-request:${entityId}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,11 +220,16 @@ describe("inbox helpers", () => {
|
||||
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();
|
||||
expect(loadLastInboxTab()).toBe("new");
|
||||
expect(loadLastInboxTab()).toBe("recent");
|
||||
|
||||
saveLastInboxTab("all");
|
||||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("maps legacy new-tab storage to recent", () => {
|
||||
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||
expect(loadLastInboxTab()).toBe("recent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||
export type InboxTab = "new" | "all";
|
||||
export type InboxTab = "recent" | "unread" | "all";
|
||||
|
||||
export interface InboxBadgeData {
|
||||
inbox: number;
|
||||
@@ -42,9 +42,11 @@ export function saveDismissedInboxItems(ids: Set<string>) {
|
||||
export function loadLastInboxTab(): InboxTab {
|
||||
try {
|
||||
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 {
|
||||
return "new";
|
||||
return "recent";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
getLatestFailedRunsByAgent,
|
||||
type InboxTab,
|
||||
normalizeTimestamp,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
saveLastInboxTab,
|
||||
sortIssuesByMostRecentActivity,
|
||||
@@ -245,8 +244,9 @@ export function Inbox() {
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "new";
|
||||
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "recent";
|
||||
const tab: InboxTab =
|
||||
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
|
||||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
@@ -333,6 +333,10 @@ export function Inbox() {
|
||||
() => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
||||
[touchedIssuesRaw],
|
||||
);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[touchedIssues],
|
||||
);
|
||||
|
||||
const agentById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -478,17 +482,22 @@ export function Inbox() {
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||
|
||||
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
||||
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
|
||||
const approvalsToRender = tab === "unread" ? actionableApprovals : filteredAllApprovals;
|
||||
const showTouchedSection =
|
||||
tab === "all"
|
||||
? showTouchedCategory && hasTouchedIssues
|
||||
: tab === "unread"
|
||||
? unreadTouchedIssues.length > 0
|
||||
: hasTouchedIssues;
|
||||
const showJoinRequestsSection =
|
||||
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : hasJoinRequests;
|
||||
const showApprovalsSection =
|
||||
tab === "new"
|
||||
tab === "unread"
|
||||
? actionableApprovals.length > 0
|
||||
: showApprovalsCategory && filteredAllApprovals.length > 0;
|
||||
const showFailedRunsSection =
|
||||
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
|
||||
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
|
||||
tab === "all" ? showFailedRunsCategory && hasRunFailures : hasRunFailures;
|
||||
const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : hasAlerts;
|
||||
|
||||
const visibleSections = [
|
||||
showFailedRunsSection ? "failed_runs" : null,
|
||||
@@ -511,13 +520,14 @@ export function Inbox() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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
|
||||
items={[
|
||||
{
|
||||
value: "new",
|
||||
label: "New",
|
||||
value: "recent",
|
||||
label: "Recent",
|
||||
},
|
||||
{ value: "unread", label: "Unread" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
@@ -572,9 +582,11 @@ export function Inbox() {
|
||||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
message={
|
||||
tab === "new"
|
||||
tab === "unread"
|
||||
? "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 />}
|
||||
<div>
|
||||
<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>
|
||||
<div className="grid gap-3">
|
||||
{approvalsToRender.map((approval) => (
|
||||
@@ -750,7 +762,7 @@ export function Inbox() {
|
||||
My Recent Issues
|
||||
</h3>
|
||||
<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 isFading = fadingOutIssues.has(issue.id);
|
||||
return (
|
||||
@@ -760,17 +772,18 @@ export function Inbox() {
|
||||
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"
|
||||
>
|
||||
{/* 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="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
|
||||
{issue.title}
|
||||
<span className="flex min-w-0 items-start gap-3 sm:order-1">
|
||||
<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 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) ? (
|
||||
<span
|
||||
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"><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="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground sm:hidden">
|
||||
·
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground sm:order-last">
|
||||
{issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user