Fix inbox badge logic and landing view
This commit is contained in:
195
ui/src/lib/inbox.test.ts
Normal file
195
ui/src/lib/inbox.test.ts
Normal 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
121
ui/src/lib/inbox.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user