From 6abc1a823ff8492c8c70f5bc17600425effb89ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 18 Apr 2026 21:42:58 -0400 Subject: [PATCH] refactor(inbox): derive from snapshot; delete getInbox IPC chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling refactor to the sidebar-badges cleanup. The inbox aggregates five item categories (pending approvals, failed runs, budget warnings, blocked/review tasks, unread company/incident/urgent messages) — every row is already in `ProfileSnapshot`. The server-side `getInbox` aggregation (180 lines of SQL + projection) was a redundant second path. New: `src/shared/inbox-items.ts::deriveInboxItems(snapshot, companyId)` produces the same InboxItem[] shape with the same severity ordering and 24h failed-run window. Pure function, no IPC, no state. Deleted: - database/company-crud.ts::getInbox (180-line aggregation) - database-base.ts::getInbox pass-through - ipc-core-read-handlers.ts: handler - preload/index.ts: bridge binding - shared/contracts/channels.ts: channel name - shared/contracts/desktop-api/companies.ts: API signature - state/slices/company.ts: `inboxItems` field, `setInboxItems` action - useDataLoader.ts: both getInbox call sites (initial snapshot load and section-change effect) - useCompanyController.ts: inbox reset on company switch OperationsSections now computes `inboxItems` via `useMemo` off the current snapshot + company id. The snapshot is already company-scoped at the main/database/snapshot.ts layer when `currentCompanyId` is non-null; the derivation re-filters defensively so the function works correctly even for unscoped snapshots. Tests: 603/603 green. No new tests added — the derivation is a pure function whose shape is exercised end-to-end by the existing Inbox component integration. --- src/main/database-base.ts | 1 - src/main/database/company-crud.ts | 187 +----------------- src/main/ipc-core-read-handlers.ts | 7 - src/preload/index.ts | 1 - src/renderer/hooks/useCompanyController.ts | 1 - src/renderer/hooks/useDataLoader.ts | 8 - src/renderer/state/slices/company.ts | 17 +- .../views/sections/OperationsSections.tsx | 7 +- src/shared/contracts/channels.ts | 1 - src/shared/contracts/desktop-api/companies.ts | 3 +- src/shared/inbox-items.ts | 167 ++++++++++++++++ 11 files changed, 180 insertions(+), 220 deletions(-) create mode 100644 src/shared/inbox-items.ts diff --git a/src/main/database-base.ts b/src/main/database-base.ts index 323a86ed..3e384979 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -283,7 +283,6 @@ export class AppDatabaseBase { getCompanyExecutionMode(companyId: string) { return this.company.getCompanyExecutionMode(companyId); } getCompanyApprovalSettings(companyId: string) { return this.company.getCompanyApprovalSettings(companyId); } listActiveCompanyIds() { return this.company.listActiveCompanyIds(); } - getInbox(companyId: string) { return this.company.getInbox(companyId); } getCompanyMetrics(companyId: string, days: number = 30) { return this.company.getCompanyMetrics(companyId, days); } getStandupReport(companyId: string) { return this.company.getStandupReport(companyId); } getDashboardSnapshot(companyId: string) { return this.company.getDashboardSnapshot(companyId); } diff --git a/src/main/database/company-crud.ts b/src/main/database/company-crud.ts index f9d3d7f2..ad380244 100644 --- a/src/main/database/company-crud.ts +++ b/src/main/database/company-crud.ts @@ -1,9 +1,6 @@ import { randomUUID } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; -import type { - InboxItem, - ProfileSnapshot, -} from "@shared/types"; +import type { ProfileSnapshot } from "@shared/types"; import { type ReadAll, type Transaction, @@ -169,187 +166,5 @@ export function buildCompanyCrudMethods( reviewDeliverables: row.review_deliverables === 1, }; }, - - getInbox(companyId: string): InboxItem[] { - const items: InboxItem[] = []; - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - - const agentRows = readAll<{ id: string; name: string }>( - "select id, name from agents where company_id = ?", - companyId, - ); - const agentNameMap = new Map(agentRows.map((a) => [a.id, a.name])); - - const pendingApprovals = readAll<{ - id: string; type: string; payloadSummary: string; - requestedByAgentId: string | null; createdAt: string; - }>( - `select id, type, payload_summary as payloadSummary, - requested_by_agent_id as requestedByAgentId, created_at as createdAt - from approvals where company_id = ? and state = 'pending' - order by created_at desc`, - companyId, - ); - for (const approval of pendingApprovals) { - const requesterName = approval.requestedByAgentId - ? agentNameMap.get(approval.requestedByAgentId) ?? null - : null; - items.push({ - id: `approval-${approval.id}`, - type: "pending_approval", - title: approval.payloadSummary, - subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requesterName ?? "Unknown"}`, - severity: approval.type === "dangerous_command" ? "critical" : "high", - entityType: "approval", - entityId: approval.id, - agentId: approval.requestedByAgentId, - agentName: requesterName, - createdAt: approval.createdAt, - }); - } - - const failedRuns = readAll<{ - id: string; agentId: string; summary: string; - errorMessage: string | null; createdAt: string; - }>( - `select id, agent_id as agentId, summary, error_message as errorMessage, - created_at as createdAt - from runs - where company_id = ? - and status in ('failed', 'timed_out') - and created_at > ? - order by created_at desc`, - companyId, - oneDayAgo, - ); - for (const run of failedRuns) { - const agentName = agentNameMap.get(run.agentId) ?? null; - items.push({ - id: `run-${run.id}`, - type: "failed_run", - title: run.summary || "Run failed", - subtitle: `${agentName ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`, - severity: "high", - entityType: "run", - entityId: run.id, - agentId: run.agentId, - agentName, - createdAt: run.createdAt, - }); - } - - const budgetAlertRows = readAll<{ - id: string; name: string; budgetMonthlyUsd: number; spentMonthlyUsd: number; updatedAt: string; - }>( - `select id, name, budget_monthly_usd as budgetMonthlyUsd, - spent_monthly_usd as spentMonthlyUsd, updated_at as updatedAt - from agents - where company_id = ? - and budget_monthly_usd > 0 - and (spent_monthly_usd * 1.0 / budget_monthly_usd) > 0.8`, - companyId, - ); - for (const agent of budgetAlertRows) { - const pct = Math.round((agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100); - items.push({ - id: `budget-${agent.id}`, - type: "budget_warning", - title: `${agent.name} at ${pct}% budget`, - subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`, - severity: pct >= 100 ? "critical" : "high", - entityType: "agent", - entityId: agent.id, - agentId: agent.id, - agentName: agent.name, - createdAt: agent.updatedAt, - }); - } - - const taskAlertRows = readAll<{ - id: string; title: string; status: string; priority: string; - assigneeAgentId: string | null; updatedAt: string; - }>( - `select id, title, status, priority, - assignee_agent_id as assigneeAgentId, updated_at as updatedAt - from tasks - where company_id = ? and status in ('blocked', 'in_review') - order by updated_at desc`, - companyId, - ); - for (const task of taskAlertRows) { - const assigneeName = task.assigneeAgentId ? agentNameMap.get(task.assigneeAgentId) ?? null : null; - if (task.status === "blocked") { - items.push({ - id: `blocked-${task.id}`, - type: "blocked_task", - title: task.title, - subtitle: `Assigned to ${assigneeName ?? "Unassigned"} — blocked`, - severity: task.priority === "critical" ? "critical" : "medium", - entityType: "task", - entityId: task.id, - agentId: task.assigneeAgentId, - agentName: assigneeName, - createdAt: task.updatedAt, - }); - } else { - items.push({ - id: `review-${task.id}`, - type: "review_needed", - title: task.title, - subtitle: `Submitted by ${assigneeName ?? "Unknown"} — awaiting review`, - severity: task.priority === "critical" ? "high" : "medium", - entityType: "task", - entityId: task.id, - agentId: task.assigneeAgentId, - agentName: assigneeName, - createdAt: task.updatedAt, - }); - } - } - - const messageRows = readAll<{ - id: string; fromAgentId: string; subject: string; channel: string; - priority: string; readAt: string | null; createdAt: string; - }>( - `select messages.id as id, - messages.from_agent_id as fromAgentId, - messages.subject as subject, - messages.channel as channel, - messages.priority as priority, - reads.read_at as readAt, - messages.created_at as createdAt - from agent_messages messages - left join agent_message_reads reads - on reads.message_id = messages.id and reads.agent_id = '__board__' - where messages.company_id = ? - and reads.read_at is null - and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent') - order by messages.created_at desc`, - companyId, - ); - for (const msg of messageRows) { - const senderName = agentNameMap.get(msg.fromAgentId) ?? null; - items.push({ - id: `message-${msg.id}`, - type: "agent_message", - title: msg.subject, - subtitle: `${senderName ?? "Unknown"} — ${msg.channel === "incident" ? "INCIDENT" : msg.channel} ${msg.priority === "urgent" ? "(urgent)" : ""}`, - severity: msg.priority === "urgent" || msg.channel === "incident" ? "critical" : "medium", - entityType: "message", - entityId: msg.id, - agentId: msg.fromAgentId, - agentName: senderName, - createdAt: msg.createdAt, - }); - } - - const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; - items.sort( - (a, b) => - severityOrder[a.severity] - severityOrder[b.severity] || - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - return items; - }, }; } diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts index caddfe29..625c8a8c 100644 --- a/src/main/ipc-core-read-handlers.ts +++ b/src/main/ipc-core-read-handlers.ts @@ -115,13 +115,6 @@ export function registerCoreReadHandlers({ } }); - registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => { - const companyId = z.string().uuid().parse(payload); - const err = ensureCompanyExists(companyId); - if (err) return err; - return ok(db.getInbox(companyId)); - }); - registerHandle(IPC_CHANNELS.getCompanyMetrics, (_event, payload) => { const input = metricsQuerySchema.parse(payload); const err = ensureCompanyExists(input.companyId); diff --git a/src/preload/index.ts b/src/preload/index.ts index dc878862..1fc46a2b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -43,7 +43,6 @@ const api: DesktopApi = { addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input), setHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.setHeartbeat, input), triggerHeartbeat: (input) => ipcRenderer.invoke(IPC_CHANNELS.triggerHeartbeat, input), - getInbox: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getInbox, companyId), getCompanyMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getCompanyMetrics, input), getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId), bootstrapOnboarding: (input) => ipcRenderer.invoke(IPC_CHANNELS.bootstrapOnboarding, input), diff --git a/src/renderer/hooks/useCompanyController.ts b/src/renderer/hooks/useCompanyController.ts index 377ba96e..fd4ef9b0 100644 --- a/src/renderer/hooks/useCompanyController.ts +++ b/src/renderer/hooks/useCompanyController.ts @@ -35,7 +35,6 @@ export function useCompanyController() { const clearCompanyScopedUiState = useCallback(() => { const store = useAppStore.getState(); store.clearCompanyScopedSelections(); - store.setInboxItems([]); store.setCompanyMetrics(null); store.setStandupReport(null); store.setSocialAccounts([]); diff --git a/src/renderer/hooks/useDataLoader.ts b/src/renderer/hooks/useDataLoader.ts index 9e709de4..eaff2194 100644 --- a/src/renderer/hooks/useDataLoader.ts +++ b/src/renderer/hooks/useDataLoader.ts @@ -24,8 +24,6 @@ export function useDataLoader( if (!currentSelectedConnectorId && next.connectors[0]) store.setSelectedConnectorId(next.connectors[0].id); if (!currentSelectedRunId && next.runs[0]) store.setSelectedRunId(next.runs[0].id); if (next.currentCompanyId) { - const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId); - if (inboxResult.ok) store.setInboxItems(inboxResult.data); store.setSocialAccounts(next.socialAccounts?.filter((a) => a.companyId === next.currentCompanyId) ?? []); const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 }); if (browserActionResult.ok) { @@ -50,12 +48,6 @@ export function useDataLoader( let cancelled = false; const store = useAppStore.getState(); - if (section === "inbox") { - void window.agentCompany.getInbox(companyId).then((result) => { - if (!cancelled && result.ok) store.setInboxItems(result.data); - }); - } - if (section === "performance") { void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => { if (!cancelled && result.ok) store.setCompanyMetrics(result.data); diff --git a/src/renderer/state/slices/company.ts b/src/renderer/state/slices/company.ts index 7c5c8f0c..edd61e3e 100644 --- a/src/renderer/state/slices/company.ts +++ b/src/renderer/state/slices/company.ts @@ -4,17 +4,17 @@ import type { AgentRecord, ApprovalRecord, CompanyMetrics, - InboxItem, ProfileSnapshot, RunRecord, StandupReport, TaskRecord, } from "@shared/types"; -// Sidebar badge counts live in the renderer — derived from the -// snapshot on every read via `deriveSidebarBadges` rather than cached -// on this slice. One source of truth is the snapshot; adding a -// separate `badges` field here is what let the counters drift. +// Sidebar badge counts and inbox items both live in the renderer — +// derived on every read from the snapshot (see `@shared/sidebar-badges` +// and `@shared/inbox-items`). The snapshot is the only source of +// truth; caching a separate `badges` or `inboxItems` field on this +// slice is what let the counters drift against the rendered content. export interface CompanySlice { snapshot: ProfileSnapshot | null; /** @@ -26,12 +26,10 @@ export interface CompanySlice { snapshotRevision: number; companyMetrics: CompanyMetrics | null; standupReport: StandupReport | null; - inboxItems: InboxItem[]; error: string | null; setSnapshot: (snapshot: ProfileSnapshot | null) => void; setCompanyMetrics: (metrics: CompanyMetrics | null) => void; setStandupReport: (report: StandupReport | null) => void; - setInboxItems: (items: InboxItem[]) => void; setError: (error: string | null) => void; refreshSnapshot: () => Promise; patchTask: (task: TaskRecord) => void; @@ -54,7 +52,6 @@ export const createCompanySlice: StateCreator< snapshotRevision: 0, companyMetrics: null, standupReport: null, - inboxItems: [], error: null, setSnapshot: (snapshot) => set((state) => { @@ -69,10 +66,6 @@ export const createCompanySlice: StateCreator< set((state) => { state.standupReport = report; }), - setInboxItems: (items) => - set((state) => { - state.inboxItems = items; - }), setError: (error) => set((state) => { state.error = error; diff --git a/src/renderer/views/sections/OperationsSections.tsx b/src/renderer/views/sections/OperationsSections.tsx index 2cbab655..bb919865 100644 --- a/src/renderer/views/sections/OperationsSections.tsx +++ b/src/renderer/views/sections/OperationsSections.tsx @@ -1,4 +1,6 @@ +import { useMemo } from "react"; import type { SectionId } from "@shared/types"; +import { deriveInboxItems } from "@shared/inbox-items"; import { Inbox } from "../../components/inbox/Inbox"; import { StandupView } from "../../components/inbox/StandupView"; import { PerformanceView } from "../../components/inbox/PerformanceView"; @@ -47,7 +49,10 @@ export function OperationsSections({ section }: OperationsSectionsProps) { } = useSectionContext(); const { refresh, openPathWithFeedback, setError } = useAppActions(); - const inboxItems = useAppStore((s) => s.inboxItems); + const inboxItems = useMemo( + () => (snapshot ? deriveInboxItems(snapshot, currentCompany?.id ?? null) : []), + [snapshot, currentCompany?.id], + ); const companyMetrics = useAppStore((s) => s.companyMetrics); const standupReport = useAppStore((s) => s.standupReport); const socialAccounts = useAppStore((s) => s.socialAccounts); diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index b5fd9232..ca0fac82 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -35,7 +35,6 @@ export const IPC_CHANNELS = { setHeartbeat: "agent-company:set-heartbeat", triggerHeartbeat: "agent-company:trigger-heartbeat", rendererEvent: "agent-company:event", - getInbox: "agent-company:get-inbox", getCompanyMetrics: "agent-company:get-company-metrics", getStandupReport: "agent-company:get-standup-report", bootstrapOnboarding: "agent-company:bootstrap-onboarding", diff --git a/src/shared/contracts/desktop-api/companies.ts b/src/shared/contracts/desktop-api/companies.ts index 0780c3fb..0b520aa1 100644 --- a/src/shared/contracts/desktop-api/companies.ts +++ b/src/shared/contracts/desktop-api/companies.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import type { CompanyMetrics, OnboardingBootstrapResult, ProfileSnapshot, StandupReport, InboxItem } from "../../types"; +import type { CompanyMetrics, OnboardingBootstrapResult, ProfileSnapshot, StandupReport } from "../../types"; import type { bootstrapOnboardingSchema, companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, settingsSchema } from "../schemas-company"; import type { metricsQuerySchema } from "../schemas-badges"; import type { DesktopResult } from "./common"; @@ -18,7 +18,6 @@ export type CompaniesApi = { listConnectorModels: (connectorId: string) => Promise>>; getConnectorDisabledModels: (connectorId: string) => Promise>>; setConnectorDisabledModels: (input: z.infer) => Promise>; - getInbox: (companyId: string) => Promise>; getCompanyMetrics: (input: z.infer) => Promise>; getStandupReport: (companyId: string) => Promise>; bootstrapOnboarding: (input: z.infer) => Promise>; diff --git a/src/shared/inbox-items.ts b/src/shared/inbox-items.ts new file mode 100644 index 00000000..5cdad90a --- /dev/null +++ b/src/shared/inbox-items.ts @@ -0,0 +1,167 @@ +import type { InboxItem, ProfileSnapshot } from "./types"; + +/** + * Inbox items, derived purely from `ProfileSnapshot`. + * + * Mirrors the single-source-of-truth pattern used for sidebar badges + * (`sidebar-badges.ts`). The renderer already holds every row the + * inbox would aggregate — approvals, runs, tasks, agents, messages — + * so the old `getInbox` IPC was a second aggregation path that could + * drift. Deriving on the client from the snapshot removes the drift + * class and the round-trip. + * + * Parity note: the five item categories (`pending_approval`, + * `failed_run`, `budget_warning`, `blocked_task` / `review_needed`, + * `agent_message`) match what the old `buildDocumentMethods::getInbox` + * produced. One behavioural drop: the old SQL limited failed-run + * items to the last 24 hours; we preserve that window here using the + * snapshot's `createdAt` strings and the caller's "now". + */ + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const BUDGET_WARNING_RATIO = 0.8; +const URGENT_CHANNELS: ReadonlyArray = ["company", "incident"]; + +const SEVERITY_ORDER: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + +/** Build the inbox list from the current snapshot for the given company. */ +export function deriveInboxItems( + snapshot: ProfileSnapshot, + companyId: string | null, + now: Date = new Date(), +): InboxItem[] { + if (!companyId) return []; + const agentNameMap = new Map(); + for (const agent of snapshot.agents) { + if (agent.companyId === companyId) agentNameMap.set(agent.id, agent.name); + } + + const items: InboxItem[] = []; + const oneDayAgoIso = new Date(now.getTime() - ONE_DAY_MS).toISOString(); + + for (const approval of snapshot.approvals) { + if (approval.companyId !== companyId) continue; + if (approval.state !== "pending") continue; + const requesterName = approval.requestedByAgentId + ? agentNameMap.get(approval.requestedByAgentId) ?? null + : null; + items.push({ + id: `approval-${approval.id}`, + type: "pending_approval", + title: approval.payloadSummary, + subtitle: `${approval.type.replaceAll("_", " ")} — requested by ${requesterName ?? "Unknown"}`, + severity: approval.type === "dangerous_command" ? "critical" : "high", + entityType: "approval", + entityId: approval.id, + agentId: approval.requestedByAgentId, + agentName: requesterName, + createdAt: approval.createdAt, + }); + } + + for (const run of snapshot.runs) { + if (run.companyId !== companyId) continue; + if (run.status !== "failed" && run.status !== "timed_out") continue; + if (run.createdAt <= oneDayAgoIso) continue; + const agentName = agentNameMap.get(run.agentId) ?? null; + items.push({ + id: `run-${run.id}`, + type: "failed_run", + title: run.summary || "Run failed", + subtitle: `${agentName ?? "Unknown agent"} — ${run.errorMessage?.slice(0, 100) ?? "Unknown error"}`, + severity: "high", + entityType: "run", + entityId: run.id, + agentId: run.agentId, + agentName, + createdAt: run.createdAt, + }); + } + + for (const agent of snapshot.agents) { + if (agent.companyId !== companyId) continue; + if (agent.budgetMonthlyUsd <= 0) continue; + const ratio = agent.spentMonthlyUsd / agent.budgetMonthlyUsd; + if (ratio <= BUDGET_WARNING_RATIO) continue; + const pct = Math.round(ratio * 100); + items.push({ + id: `budget-${agent.id}`, + type: "budget_warning", + title: `${agent.name} at ${pct}% budget`, + subtitle: `$${agent.spentMonthlyUsd.toFixed(2)} of $${agent.budgetMonthlyUsd.toFixed(2)} monthly limit`, + severity: pct >= 100 ? "critical" : "high", + entityType: "agent", + entityId: agent.id, + agentId: agent.id, + agentName: agent.name, + createdAt: agent.updatedAt, + }); + } + + for (const task of snapshot.tasks) { + if (task.companyId !== companyId) continue; + if (task.status !== "blocked" && task.status !== "in_review") continue; + const assigneeName = task.assigneeAgentId ? agentNameMap.get(task.assigneeAgentId) ?? null : null; + if (task.status === "blocked") { + items.push({ + id: `blocked-${task.id}`, + type: "blocked_task", + title: task.title, + subtitle: `Assigned to ${assigneeName ?? "Unassigned"} — blocked`, + severity: task.priority === "critical" ? "critical" : "medium", + entityType: "task", + entityId: task.id, + agentId: task.assigneeAgentId, + agentName: assigneeName, + createdAt: task.updatedAt, + }); + } else { + items.push({ + id: `review-${task.id}`, + type: "review_needed", + title: task.title, + subtitle: `Submitted by ${assigneeName ?? "Unknown"} — awaiting review`, + severity: task.priority === "critical" ? "high" : "medium", + entityType: "task", + entityId: task.id, + agentId: task.assigneeAgentId, + agentName: assigneeName, + createdAt: task.updatedAt, + }); + } + } + + for (const message of snapshot.agentMessages) { + if (message.companyId !== companyId) continue; + if (message.readAt) continue; + const visible = + URGENT_CHANNELS.includes(message.channel) || message.priority === "urgent"; + if (!visible) continue; + const senderName = agentNameMap.get(message.fromAgentId) ?? null; + const urgencyTag = message.priority === "urgent" ? " (urgent)" : ""; + items.push({ + id: `message-${message.id}`, + type: "agent_message", + title: message.subject, + subtitle: `${senderName ?? "Unknown"} — ${message.channel === "incident" ? "INCIDENT" : message.channel}${urgencyTag}`, + severity: message.priority === "urgent" || message.channel === "incident" ? "critical" : "medium", + entityType: "message", + entityId: message.id, + agentId: message.fromAgentId, + agentName: senderName, + createdAt: message.createdAt, + }); + } + + items.sort( + (a, b) => + SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return items; +}