Fix inbox badge logic and landing view

This commit is contained in:
Dotta
2026-03-10 22:55:45 -05:00
parent 92aef9bae8
commit 21d2b075e7
14 changed files with 453 additions and 230 deletions

195
ui/src/lib/inbox.test.ts Normal file
View File

@@ -0,0 +1,195 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import { computeInboxBadgeData, getUnreadTouchedIssues } from "./inbox";
function makeApproval(status: Approval["status"]): Approval {
return {
id: `approval-${status}`,
companyId: "company-1",
type: "hire_agent",
requestedByAgentId: null,
requestedByUserId: null,
status,
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeJoinRequest(id: string): JoinRequest {
return {
id,
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestEmailSnapshot: null,
requestIp: "127.0.0.1",
requestingUserId: null,
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, agentId = "agent-1"): HeartbeatRun {
return {
id,
companyId: "company-1",
agentId,
invocationSource: "assignment",
triggerDetail: null,
status,
error: null,
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
errorCode: null,
externalRunId: null,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
startedAt: new Date(createdAt),
finishedAt: null,
createdAt: new Date(createdAt),
updatedAt: new Date(createdAt),
};
}
function makeIssue(id: string, isUnreadForMe: boolean): Issue {
return {
id,
companyId: "company-1",
projectId: null,
goalId: null,
parentId: null,
title: `Issue ${id}`,
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
identifier: `PAP-${id}`,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"),
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
isUnreadForMe,
};
}
const dashboard: DashboardSummary = {
companyId: "company-1",
agents: {
active: 1,
running: 0,
paused: 0,
error: 1,
},
tasks: {
open: 1,
inProgress: 0,
blocked: 0,
done: 0,
},
costs: {
monthSpendCents: 900,
monthBudgetCents: 1000,
monthUtilizationPercent: 90,
},
pendingApprovals: 1,
};
describe("inbox helpers", () => {
it("counts the same inbox sources the badge uses", () => {
const result = computeInboxBadgeData({
approvals: [makeApproval("pending"), makeApproval("approved")],
joinRequests: [makeJoinRequest("join-1")],
dashboard,
heartbeatRuns: [
makeRun("run-old", "failed", "2026-03-11T00:00:00.000Z"),
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
touchedIssues: [makeIssue("1", true), makeIssue("2", false)],
dismissed: new Set<string>(),
});
expect(result).toEqual({
inbox: 6,
approvals: 1,
failedRuns: 2,
joinRequests: 1,
unreadTouchedIssues: 1,
alerts: 1,
});
});
it("drops dismissed runs and alerts from the computed badge", () => {
const result = computeInboxBadgeData({
approvals: [],
joinRequests: [],
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
touchedIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
});
expect(result).toEqual({
inbox: 0,
approvals: 0,
failedRuns: 0,
joinRequests: 0,
unreadTouchedIssues: 0,
alerts: 0,
});
});
it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)];
expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]);
expect(issues).toHaveLength(2);
});
});

121
ui/src/lib/inbox.ts Normal file
View File

@@ -0,0 +1,121 @@
import type {
Approval,
DashboardSummary,
HeartbeatRun,
Issue,
JoinRequest,
} from "@paperclipai/shared";
export const RECENT_ISSUES_LIMIT = 100;
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 interface InboxBadgeData {
inbox: number;
approvals: number;
failedRuns: number;
joinRequests: number;
unreadTouchedIssues: number;
alerts: number;
}
export function loadDismissedInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveDismissedInboxItems(ids: Set<string>) {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
}
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
}
export function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function issueLastActivityTimestamp(issue: Issue): number {
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
const updatedAt = normalizeTimestamp(issue.updatedAt);
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
return updatedAt;
}
export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
}
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
return issues.filter((issue) => issue.isUnreadForMe);
}
export function computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
touchedIssues,
dismissed,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
touchedIssues: Issue[];
dismissed: Set<string>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const unreadTouchedIssues = getUnreadTouchedIssues(touchedIssues).length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
const showAggregateAgentError =
agentErrorCount > 0 &&
failedRuns === 0 &&
!dismissed.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
unreadTouchedIssues,
alerts,
};
}