Fix inbox badge logic and landing view
This commit is contained in:
@@ -18,5 +18,4 @@ export interface DashboardSummary {
|
|||||||
monthUtilizationPercent: number;
|
monthUtilizationPercent: number;
|
||||||
};
|
};
|
||||||
pendingApprovals: number;
|
pendingApprovals: number;
|
||||||
staleTasks: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { joinRequests } from "@paperclipai/db";
|
import { joinRequests } from "@paperclipai/db";
|
||||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||||
import { issueService } from "../services/issues.js";
|
|
||||||
import { accessService } from "../services/access.js";
|
import { accessService } from "../services/access.js";
|
||||||
import { dashboardService } from "../services/dashboard.js";
|
import { dashboardService } from "../services/dashboard.js";
|
||||||
import { assertCompanyAccess } from "./authz.js";
|
import { assertCompanyAccess } from "./authz.js";
|
||||||
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
|
|||||||
export function sidebarBadgeRoutes(db: Db) {
|
export function sidebarBadgeRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = sidebarBadgeService(db);
|
const svc = sidebarBadgeService(db);
|
||||||
const issueSvc = issueService(db);
|
|
||||||
const access = accessService(db);
|
const access = accessService(db);
|
||||||
const dashboard = dashboardService(db);
|
const dashboard = dashboardService(db);
|
||||||
|
|
||||||
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
|
|||||||
joinRequests: joinRequestCount,
|
joinRequests: joinRequestCount,
|
||||||
});
|
});
|
||||||
const summary = await dashboard.summary(companyId);
|
const summary = await dashboard.summary(companyId);
|
||||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
|
||||||
const hasFailedRuns = badges.failedRuns > 0;
|
const hasFailedRuns = badges.failedRuns > 0;
|
||||||
const alertsCount =
|
const alertsCount =
|
||||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
|
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
||||||
|
|
||||||
res.json(badges);
|
res.json(badges);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,19 +32,6 @@ export function dashboardService(db: Db) {
|
|||||||
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||||
|
|
||||||
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
|
|
||||||
const staleTasks = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(issues)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(issues.companyId, companyId),
|
|
||||||
eq(issues.status, "in_progress"),
|
|
||||||
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
|
||||||
|
|
||||||
const agentCounts: Record<string, number> = {
|
const agentCounts: Record<string, number> = {
|
||||||
active: 0,
|
active: 0,
|
||||||
running: 0,
|
running: 0,
|
||||||
@@ -107,7 +94,6 @@ export function dashboardService(db: Db) {
|
|||||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||||
},
|
},
|
||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
staleTasks,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1411,23 +1411,5 @@ export function issueService(db: Db) {
|
|||||||
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
|
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
staleCount: async (companyId: string, minutes = 60) => {
|
|
||||||
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
|
|
||||||
const result = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(issues)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(issues.companyId, companyId),
|
|
||||||
eq(issues.status, "in_progress"),
|
|
||||||
isNull(issues.hiddenAt),
|
|
||||||
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((rows) => rows[0]);
|
|
||||||
|
|
||||||
return Number(result?.count ?? 0);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function boardRoutes() {
|
|||||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||||
<Route path="costs" element={<Costs />} />
|
<Route path="costs" element={<Costs />} />
|
||||||
<Route path="activity" element={<Activity />} />
|
<Route path="activity" element={<Activity />} />
|
||||||
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
|
<Route path="inbox" element={<Navigate to="/inbox/all" replace />} />
|
||||||
<Route path="inbox/new" element={<Inbox />} />
|
<Route path="inbox/new" element={<Inbox />} />
|
||||||
<Route path="inbox/all" element={<Inbox />} />
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function CommandPalette() {
|
|||||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => go("/inbox")}>
|
<CommandItem onSelect={() => go("/inbox/all")}>
|
||||||
<Inbox className="mr-2 h-4 w-4" />
|
<Inbox className="mr-2 h-4 w-4" />
|
||||||
Inbox
|
Inbox
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { NavLink, useLocation } from "@/lib/router";
|
import { NavLink, useLocation } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
House,
|
House,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
@@ -8,11 +7,10 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Inbox,
|
Inbox,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
interface MobileBottomNavProps {
|
interface MobileBottomNavProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -39,12 +37,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
const { data: sidebarBadges } = useQuery({
|
|
||||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
|
||||||
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = useMemo<MobileNavItem[]>(
|
const items = useMemo<MobileNavItem[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -54,13 +47,13 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|||||||
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
||||||
{
|
{
|
||||||
type: "link",
|
type: "link",
|
||||||
to: "/inbox",
|
to: "/inbox/all",
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
badge: sidebarBadges?.inbox,
|
badge: inboxBadge.inbox,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[openNewIssue, sidebarBadges?.inbox],
|
[openNewIssue, inboxBadge.inbox],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,19 +17,15 @@ import { SidebarProjects } from "./SidebarProjects";
|
|||||||
import { SidebarAgents } from "./SidebarAgents";
|
import { SidebarAgents } from "./SidebarAgents";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const { data: sidebarBadges } = useQuery({
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
|
||||||
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
});
|
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||||
@@ -77,12 +73,12 @@ export function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
to="/inbox"
|
to="/inbox/all"
|
||||||
label="Inbox"
|
label="Inbox"
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
badge={sidebarBadges?.inbox}
|
badge={inboxBadge.inbox}
|
||||||
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
|
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
||||||
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
|
alert={inboxBadge.failedRuns > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
101
ui/src/hooks/useInboxBadge.ts
Normal file
101
ui/src/hooks/useInboxBadge.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { accessApi } from "../api/access";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { approvalsApi } from "../api/approvals";
|
||||||
|
import { dashboardApi } from "../api/dashboard";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import {
|
||||||
|
computeInboxBadgeData,
|
||||||
|
loadDismissedInboxItems,
|
||||||
|
saveDismissedInboxItems,
|
||||||
|
} from "../lib/inbox";
|
||||||
|
|
||||||
|
const TOUCHED_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||||
|
|
||||||
|
export function useDismissedInboxItems() {
|
||||||
|
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== "paperclip:inbox:dismissed") return;
|
||||||
|
setDismissed(loadDismissedInboxItems());
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
return () => window.removeEventListener("storage", handleStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = (id: string) => {
|
||||||
|
setDismissed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(id);
|
||||||
|
saveDismissedInboxItems(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { dismissed, dismiss };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
|
const { dismissed } = useDismissedInboxItems();
|
||||||
|
|
||||||
|
const { data: approvals = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.approvals.list(companyId!),
|
||||||
|
queryFn: () => approvalsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: joinRequests = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.access.joinRequests(companyId!),
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await accessApi.listJoinRequests(companyId!, "pending_approval");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!companyId,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dashboard } = useQuery({
|
||||||
|
queryKey: queryKeys.dashboard(companyId!),
|
||||||
|
queryFn: () => dashboardApi.summary(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: touchedIssues = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
|
||||||
|
queryFn: () =>
|
||||||
|
issuesApi.list(companyId!, {
|
||||||
|
touchedByUserId: "me",
|
||||||
|
status: TOUCHED_ISSUE_STATUSES,
|
||||||
|
}),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: heartbeatRuns = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.heartbeats(companyId!),
|
||||||
|
queryFn: () => heartbeatsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
computeInboxBadgeData({
|
||||||
|
approvals,
|
||||||
|
joinRequests,
|
||||||
|
dashboard,
|
||||||
|
heartbeatRuns,
|
||||||
|
touchedIssues,
|
||||||
|
dismissed,
|
||||||
|
}),
|
||||||
|
[approvals, joinRequests, dashboard, heartbeatRuns, touchedIssues, dismissed],
|
||||||
|
);
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -255,7 +255,7 @@ export function Dashboard() {
|
|||||||
to="/approvals"
|
to="/approvals"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
{data.staleTasks} stale tasks
|
Awaiting board review
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
XCircle,
|
XCircle,
|
||||||
X,
|
X,
|
||||||
@@ -41,11 +40,14 @@ import {
|
|||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
const RECENT_ISSUES_LIMIT = 100;
|
getLatestFailedRunsByAgent,
|
||||||
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
normalizeTimestamp,
|
||||||
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
RECENT_ISSUES_LIMIT,
|
||||||
|
sortIssuesByMostRecentActivity,
|
||||||
|
} from "../lib/inbox";
|
||||||
|
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
type InboxTab = "new" | "all";
|
type InboxTab = "new" | "all";
|
||||||
type InboxCategoryFilter =
|
type InboxCategoryFilter =
|
||||||
@@ -54,46 +56,14 @@ type InboxCategoryFilter =
|
|||||||
| "join_requests"
|
| "join_requests"
|
||||||
| "approvals"
|
| "approvals"
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts"
|
| "alerts";
|
||||||
| "stale_work";
|
|
||||||
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
type SectionKey =
|
type SectionKey =
|
||||||
| "issues_i_touched"
|
| "issues_i_touched"
|
||||||
| "join_requests"
|
| "join_requests"
|
||||||
| "approvals"
|
| "approvals"
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts"
|
| "alerts";
|
||||||
| "stale_work";
|
|
||||||
|
|
||||||
const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
|
||||||
|
|
||||||
function loadDismissed(): Set<string> {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
|
||||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDismissed(ids: Set<string>) {
|
|
||||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDismissedItems() {
|
|
||||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed);
|
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
|
||||||
setDismissed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(id);
|
|
||||||
saveDismissed(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { dismissed, dismiss };
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUN_SOURCE_LABELS: Record<string, string> = {
|
const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||||
timer: "Scheduled",
|
timer: "Scheduled",
|
||||||
@@ -102,32 +72,6 @@ const RUN_SOURCE_LABELS: Record<string, string> = {
|
|||||||
automation: "Automation",
|
automation: "Automation",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStaleIssues(issues: Issue[]): Issue[] {
|
|
||||||
const now = Date.now();
|
|
||||||
return issues
|
|
||||||
.filter(
|
|
||||||
(i) =>
|
|
||||||
["in_progress", "todo"].includes(i.status) &&
|
|
||||||
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS,
|
|
||||||
)
|
|
||||||
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||||
@@ -138,23 +82,6 @@ function runFailureMessage(run: HeartbeatRun): string {
|
|||||||
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTimestamp(value: string | Date | null | undefined): number {
|
|
||||||
if (!value) return 0;
|
|
||||||
const timestamp = new Date(value).getTime();
|
|
||||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
const context = run.contextSnapshot;
|
const context = run.contextSnapshot;
|
||||||
if (!context) return null;
|
if (!context) return null;
|
||||||
@@ -315,7 +242,7 @@ export function Inbox() {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||||
const { dismissed, dismiss } = useDismissedItems();
|
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||||
|
|
||||||
const pathSegment = location.pathname.split("/").pop() ?? "new";
|
const pathSegment = location.pathname.split("/").pop() ?? "new";
|
||||||
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
|
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
|
||||||
@@ -397,22 +324,13 @@ export function Inbox() {
|
|||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const staleIssues = useMemo(
|
|
||||||
() => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)),
|
|
||||||
[issues, dismissed],
|
|
||||||
);
|
|
||||||
const sortByMostRecentActivity = useCallback(
|
|
||||||
(a: Issue, b: Issue) => {
|
|
||||||
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
|
|
||||||
if (activityDiff !== 0) return activityDiff;
|
|
||||||
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const touchedIssues = useMemo(
|
const touchedIssues = useMemo(
|
||||||
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
() => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
||||||
[sortByMostRecentActivity, touchedIssuesRaw],
|
[touchedIssuesRaw],
|
||||||
|
);
|
||||||
|
const unreadTouchedIssues = useMemo(
|
||||||
|
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||||
|
[touchedIssues],
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentById = useMemo(() => {
|
const agentById = useMemo(() => {
|
||||||
@@ -547,9 +465,8 @@ export function Inbox() {
|
|||||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||||
!dismissed.has("alert:budget");
|
!dismissed.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const hasStale = staleIssues.length > 0;
|
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = touchedIssues.length > 0;
|
const hasTouchedIssues = unreadTouchedIssues.length > 0;
|
||||||
|
|
||||||
const showJoinRequestsCategory =
|
const showJoinRequestsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||||
@@ -559,7 +476,6 @@ export function Inbox() {
|
|||||||
const showFailedRunsCategory =
|
const showFailedRunsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||||
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
|
||||||
|
|
||||||
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
||||||
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
|
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
|
||||||
@@ -572,12 +488,10 @@ export function Inbox() {
|
|||||||
const showFailedRunsSection =
|
const showFailedRunsSection =
|
||||||
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
|
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
|
||||||
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
|
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
|
||||||
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
|
||||||
|
|
||||||
const visibleSections = [
|
const visibleSections = [
|
||||||
showFailedRunsSection ? "failed_runs" : null,
|
showFailedRunsSection ? "failed_runs" : null,
|
||||||
showAlertsSection ? "alerts" : null,
|
showAlertsSection ? "alerts" : null,
|
||||||
showStaleSection ? "stale_work" : null,
|
|
||||||
showApprovalsSection ? "approvals" : null,
|
showApprovalsSection ? "approvals" : null,
|
||||||
showJoinRequestsSection ? "join_requests" : null,
|
showJoinRequestsSection ? "join_requests" : null,
|
||||||
showTouchedSection ? "issues_i_touched" : null,
|
showTouchedSection ? "issues_i_touched" : null,
|
||||||
@@ -624,7 +538,6 @@ export function Inbox() {
|
|||||||
<SelectItem value="approvals">Approvals</SelectItem>
|
<SelectItem value="approvals">Approvals</SelectItem>
|
||||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||||
<SelectItem value="alerts">Alerts</SelectItem>
|
<SelectItem value="alerts">Alerts</SelectItem>
|
||||||
<SelectItem value="stale_work">Stale work</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -659,7 +572,7 @@ export function Inbox() {
|
|||||||
icon={InboxIcon}
|
icon={InboxIcon}
|
||||||
message={
|
message={
|
||||||
tab === "new"
|
tab === "new"
|
||||||
? "No issues you're involved in yet."
|
? "No new inbox items."
|
||||||
: "No inbox items match these filters."
|
: "No inbox items match these filters."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -828,66 +741,6 @@ export function Inbox() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStaleSection && (
|
|
||||||
<>
|
|
||||||
{showSeparatorBefore("stale_work") && <Separator />}
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Stale Work
|
|
||||||
</h3>
|
|
||||||
<div className="divide-y divide-border border border-border">
|
|
||||||
{staleIssues.map((issue) => (
|
|
||||||
<div
|
|
||||||
key={issue.id}
|
|
||||||
className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
|
|
||||||
>
|
|
||||||
{/* Status icon - left column on mobile; Clock icon on desktop */}
|
|
||||||
<span className="shrink-0 sm:hidden">
|
|
||||||
<StatusIcon status={issue.status} />
|
|
||||||
</span>
|
|
||||||
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground hidden sm:block sm:mt-0" />
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
||||||
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-1 no-underline text-inherit sm:flex-row sm:items-center sm:gap-3"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
|
||||||
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
|
|
||||||
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
|
|
||||||
<span className="shrink-0 text-xs font-mono text-muted-foreground">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
{issue.assigneeAgentId &&
|
|
||||||
(() => {
|
|
||||||
const name = agentName(issue.assigneeAgentId);
|
|
||||||
return name ? (
|
|
||||||
<span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
<span className="text-xs text-muted-foreground sm:hidden">·</span>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground sm:order-last">
|
|
||||||
updated {timeAgo(issue.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => dismiss(`stale:${issue.id}`)}
|
|
||||||
className="mt-0.5 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100 sm:mt-0"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showTouchedSection && (
|
{showTouchedSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||||
@@ -896,7 +749,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 === "new" ? 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 (
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "node",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user