Refine inbox ordering and alert-focused badges
This commit is contained in:
@@ -5,15 +5,15 @@ import { joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const;
|
||||
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
const issueSvc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const dashboard = dashboardService(db);
|
||||
|
||||
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -36,19 +36,16 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const unreadTouchedIssueCount =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? await issueSvc.countUnreadTouchedByUser(
|
||||
companyId,
|
||||
req.actor.userId,
|
||||
INBOX_ISSUE_STATUSES.join(","),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const badges = await svc.get(companyId, {
|
||||
joinRequests: joinRequestCount,
|
||||
unreadTouchedIssues: unreadTouchedIssueCount,
|
||||
});
|
||||
const summary = await dashboard.summary(companyId);
|
||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
|
||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const RECENT_ISSUES_LIMIT = 100;
|
||||
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
|
||||
@@ -390,7 +391,7 @@ export function Inbox() {
|
||||
);
|
||||
|
||||
const touchedIssues = useMemo(
|
||||
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity),
|
||||
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
||||
[sortByMostRecentActivity, touchedIssuesRaw],
|
||||
);
|
||||
|
||||
@@ -504,12 +505,8 @@ export function Inbox() {
|
||||
const hasStale = staleIssues.length > 0;
|
||||
const hasJoinRequests = joinRequests.length > 0;
|
||||
const hasTouchedIssues = touchedIssues.length > 0;
|
||||
const unreadTouchedCount = touchedIssues.filter((issue) => issue.isUnreadForMe).length;
|
||||
|
||||
const newItemCount =
|
||||
unreadTouchedCount +
|
||||
joinRequests.length +
|
||||
actionableApprovals.length +
|
||||
failedRuns.length +
|
||||
staleIssues.length +
|
||||
(showAggregateAgentError ? 1 : 0) +
|
||||
@@ -539,12 +536,12 @@ export function Inbox() {
|
||||
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
||||
|
||||
const visibleSections = [
|
||||
showTouchedSection ? "issues_i_touched" : null,
|
||||
showApprovalsSection ? "approvals" : null,
|
||||
showJoinRequestsSection ? "join_requests" : null,
|
||||
showFailedRunsSection ? "failed_runs" : null,
|
||||
showAlertsSection ? "alerts" : null,
|
||||
showStaleSection ? "stale_work" : null,
|
||||
showApprovalsSection ? "approvals" : null,
|
||||
showJoinRequestsSection ? "join_requests" : null,
|
||||
showTouchedSection ? "issues_i_touched" : null,
|
||||
].filter((key): key is SectionKey => key !== null);
|
||||
|
||||
const allLoaded =
|
||||
@@ -592,7 +589,7 @@ export function Inbox() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="issues_i_touched">Issues I touched</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
@@ -638,48 +635,6 @@ export function Inbox() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTouchedSection && (
|
||||
<>
|
||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Issues I Touched
|
||||
</h3>
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{touchedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
|
||||
>
|
||||
<span className="flex w-4 shrink-0 justify-center">
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${
|
||||
issue.isUnreadForMe
|
||||
? "bg-blue-600 dark:bg-blue-400"
|
||||
: "border border-muted-foreground/40 bg-transparent"
|
||||
}`}
|
||||
aria-label={issue.isUnreadForMe ? "Unread" : "Read"}
|
||||
/>
|
||||
</span>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showApprovalsSection && (
|
||||
<>
|
||||
{showSeparatorBefore("approvals") && <Separator />}
|
||||
@@ -895,6 +850,48 @@ export function Inbox() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTouchedSection && (
|
||||
<>
|
||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
My Recent Issues
|
||||
</h3>
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{touchedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
|
||||
>
|
||||
<span className="flex w-4 shrink-0 justify-center">
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${
|
||||
issue.isUnreadForMe
|
||||
? "bg-blue-600 dark:bg-blue-400"
|
||||
: "border border-muted-foreground/40 bg-transparent"
|
||||
}`}
|
||||
aria-label={issue.isUnreadForMe ? "Unread" : "Read"}
|
||||
/>
|
||||
</span>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user