Refine inbox tabs and layout
This commit is contained in:
@@ -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" />} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +582,10 @@ export function Inbox() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={InboxIcon}
|
icon={InboxIcon}
|
||||||
message={
|
message={
|
||||||
tab === "new"
|
tab === "unread"
|
||||||
? "No new inbox items."
|
? "No new inbox items."
|
||||||
|
: tab === "recent"
|
||||||
|
? "No recent inbox items."
|
||||||
: "No inbox items match these filters."
|
: "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">
|
||||||
|
<span className="line-clamp-2 min-w-0 flex-1 text-sm sm:line-clamp-1 sm:truncate">
|
||||||
{issue.title}
|
{issue.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
<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-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">
|
|
||||||
·
|
|
||||||
</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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user