diff --git a/packages/shared/src/types/dashboard.ts b/packages/shared/src/types/dashboard.ts index 514d0ee4..8350589d 100644 --- a/packages/shared/src/types/dashboard.ts +++ b/packages/shared/src/types/dashboard.ts @@ -18,5 +18,4 @@ export interface DashboardSummary { monthUtilizationPercent: number; }; pendingApprovals: number; - staleTasks: number; } diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 0cd302e5..03cb4cb0 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db"; import { and, eq, sql } from "drizzle-orm"; 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"; @@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js"; export function sidebarBadgeRoutes(db: Db) { const router = Router(); const svc = sidebarBadgeService(db); - const issueSvc = issueService(db); const access = accessService(db); const dashboard = dashboardService(db); @@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) { joinRequests: joinRequestCount, }); const summary = await dashboard.summary(companyId); - const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60); const hasFailedRuns = badges.failedRuns > 0; const alertsCount = (summary.agents.error > 0 && !hasFailedRuns ? 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); }); diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index cf7f32cf..991c9c61 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -32,19 +32,6 @@ export function dashboardService(db: Db) { .where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending"))) .then((rows) => Number(rows[0]?.count ?? 0)); - const staleCutoff = new Date(Date.now() - 60 * 60 * 1000); - const staleTasks = await db - .select({ count: sql`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 = { active: 0, running: 0, @@ -107,7 +94,6 @@ export function dashboardService(db: Db) { monthUtilizationPercent: Number(utilization.toFixed(2)), }, pendingApprovals, - staleTasks, }; }, }; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index f875ea53..a25d21fc 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1411,23 +1411,5 @@ export function issueService(db: Db) { 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`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); - }, }; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d47af808..8f1b3ef9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -138,7 +138,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 3defb0e6..04b4b035 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -142,7 +142,7 @@ export function CommandPalette() { Dashboard - go("/inbox")}> + go("/inbox/all")}> Inbox diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index e9e5c150..7ba83b89 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -1,6 +1,5 @@ import { useMemo } from "react"; import { NavLink, useLocation } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; import { House, CircleDot, @@ -8,11 +7,10 @@ import { Users, Inbox, } from "lucide-react"; -import { sidebarBadgesApi } from "../api/sidebarBadges"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; -import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; +import { useInboxBadge } from "../hooks/useInboxBadge"; interface MobileBottomNavProps { visible: boolean; @@ -39,12 +37,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { const location = useLocation(); const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); - - const { data: sidebarBadges } = useQuery({ - queryKey: queryKeys.sidebarBadges(selectedCompanyId!), - queryFn: () => sidebarBadgesApi.get(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); + const inboxBadge = useInboxBadge(selectedCompanyId); const items = useMemo( () => [ @@ -54,13 +47,13 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { { type: "link", to: "/agents/all", label: "Agents", icon: Users }, { type: "link", - to: "/inbox", + to: "/inbox/all", label: "Inbox", icon: Inbox, - badge: sidebarBadges?.inbox, + badge: inboxBadge.inbox, }, ], - [openNewIssue, sidebarBadges?.inbox], + [openNewIssue, inboxBadge.inbox], ); return ( diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index ae5e83d4..684c878a 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -17,19 +17,15 @@ import { SidebarProjects } from "./SidebarProjects"; import { SidebarAgents } from "./SidebarAgents"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; -import { sidebarBadgesApi } from "../api/sidebarBadges"; import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; +import { useInboxBadge } from "../hooks/useInboxBadge"; import { Button } from "@/components/ui/button"; export function Sidebar() { const { openNewIssue } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); - const { data: sidebarBadges } = useQuery({ - queryKey: queryKeys.sidebarBadges(selectedCompanyId!), - queryFn: () => sidebarBadgesApi.get(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); + const inboxBadge = useInboxBadge(selectedCompanyId); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), @@ -77,12 +73,12 @@ export function Sidebar() { 0} + badge={inboxBadge.inbox} + badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"} + alert={inboxBadge.failedRuns > 0} /> diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts new file mode 100644 index 00000000..b004850e --- /dev/null +++ b/ui/src/hooks/useInboxBadge.ts @@ -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>(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], + ); +} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts new file mode 100644 index 00000000..10183cf2 --- /dev/null +++ b/ui/src/lib/inbox.test.ts @@ -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(), + }); + + 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(["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); + }); +}); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts new file mode 100644 index 00000000..3ae9f9a9 --- /dev/null +++ b/ui/src/lib/inbox.ts @@ -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 { + 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) { + 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(); + + 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; +}): 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, + }; +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index e1f9b9b0..051fc053 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -255,7 +255,7 @@ export function Dashboard() { to="/approvals" description={ - {data.staleTasks} stale tasks + Awaiting board review } /> diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 0a3ede64..3858e88f 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -32,7 +32,6 @@ import { import { Inbox as InboxIcon, AlertTriangle, - Clock, ArrowUpRight, XCircle, X, @@ -41,11 +40,14 @@ import { import { Identity } from "../components/Identity"; 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"]); +import { + ACTIONABLE_APPROVAL_STATUSES, + getLatestFailedRunsByAgent, + normalizeTimestamp, + RECENT_ISSUES_LIMIT, + sortIssuesByMostRecentActivity, +} from "../lib/inbox"; +import { useDismissedInboxItems } from "../hooks/useInboxBadge"; type InboxTab = "new" | "all"; type InboxCategoryFilter = @@ -54,46 +56,14 @@ type InboxCategoryFilter = | "join_requests" | "approvals" | "failed_runs" - | "alerts" - | "stale_work"; + | "alerts"; type InboxApprovalFilter = "all" | "actionable" | "resolved"; type SectionKey = | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" - | "alerts" - | "stale_work"; - -const DISMISSED_KEY = "paperclip:inbox:dismissed"; - -function loadDismissed(): Set { - try { - const raw = localStorage.getItem(DISMISSED_KEY); - return raw ? new Set(JSON.parse(raw)) : new Set(); - } catch { - return new Set(); - } -} - -function saveDismissed(ids: Set) { - localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); -} - -function useDismissedItems() { - const [dismissed, setDismissed] = useState>(loadDismissed); - - const dismiss = useCallback((id: string) => { - setDismissed((prev) => { - const next = new Set(prev); - next.add(id); - saveDismissed(next); - return next; - }); - }, []); - - return { dismissed, dismiss }; -} + | "alerts"; const RUN_SOURCE_LABELS: Record = { timer: "Scheduled", @@ -102,32 +72,6 @@ const RUN_SOURCE_LABELS: Record = { 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(); - - 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 { if (!value) return null; 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."; } -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 { const context = run.contextSnapshot; if (!context) return null; @@ -315,7 +242,7 @@ export function Inbox() { const [actionError, setActionError] = useState(null); const [allCategoryFilter, setAllCategoryFilter] = useState("everything"); const [allApprovalFilter, setAllApprovalFilter] = useState("all"); - const { dismissed, dismiss } = useDismissedItems(); + const { dismissed, dismiss } = useDismissedInboxItems(); const pathSegment = location.pathname.split("/").pop() ?? "new"; const tab: InboxTab = pathSegment === "all" ? "all" : "new"; @@ -397,22 +324,13 @@ export function Inbox() { 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( - () => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), - [sortByMostRecentActivity, touchedIssuesRaw], + () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), + [touchedIssuesRaw], + ); + const unreadTouchedIssues = useMemo( + () => touchedIssues.filter((issue) => issue.isUnreadForMe), + [touchedIssues], ); const agentById = useMemo(() => { @@ -547,9 +465,8 @@ export function Inbox() { dashboard.costs.monthUtilizationPercent >= 80 && !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; - const hasStale = staleIssues.length > 0; const hasJoinRequests = joinRequests.length > 0; - const hasTouchedIssues = touchedIssues.length > 0; + const hasTouchedIssues = unreadTouchedIssues.length > 0; const showJoinRequestsCategory = allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; @@ -559,7 +476,6 @@ export function Inbox() { const showFailedRunsCategory = allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; - const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work"; const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals; const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues; @@ -572,12 +488,10 @@ export function Inbox() { const showFailedRunsSection = tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures; const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts; - const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale; const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : null, - showStaleSection ? "stale_work" : null, showApprovalsSection ? "approvals" : null, showJoinRequestsSection ? "join_requests" : null, showTouchedSection ? "issues_i_touched" : null, @@ -624,7 +538,6 @@ export function Inbox() { Approvals Failed runs Alerts - Stale work @@ -659,7 +572,7 @@ export function Inbox() { icon={InboxIcon} message={ tab === "new" - ? "No issues you're involved in yet." + ? "No new inbox items." : "No inbox items match these filters." } /> @@ -828,66 +741,6 @@ export function Inbox() { )} - {showStaleSection && ( - <> - {showSeparatorBefore("stale_work") && } -
-

- Stale Work -

-
- {staleIssues.map((issue) => ( -
- {/* Status icon - left column on mobile; Clock icon on desktop */} - - - - - - - - {issue.title} - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {issue.assigneeAgentId && - (() => { - const name = agentName(issue.assigneeAgentId); - return name ? ( - - ) : null; - })()} - · - - updated {timeAgo(issue.updatedAt)} - - - - -
- ))} -
-
- - )} - {showTouchedSection && ( <> {showSeparatorBefore("issues_i_touched") && } @@ -896,7 +749,7 @@ export function Inbox() { My Recent Issues
- {touchedIssues.map((issue) => { + {(tab === "new" ? unreadTouchedIssues : touchedIssues).map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 9f6250a3..f624398e 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: "jsdom", + environment: "node", }, });