diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index d8451b7bd..726e6f177 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -298,6 +298,10 @@ export function InboxSignalsTab() { bulk_size: 1, rank: preMutationRank, list_size: preMutationListSize, + // Snapshot priority/actionability from the pre-mutation target — + // by the time this fires the report has been removed from `reports`. + priority: target?.priority ?? null, + actionability: target?.actionability ?? null, ...(isSnooze ? {} : { @@ -492,6 +496,38 @@ export function InboxSignalsTab() { if (isLoading) return; if (inboxViewedFiredRef.current) return; inboxViewedFiredRef.current = true; + const priorityCounts = { + P0: 0, + P1: 0, + P2: 0, + P3: 0, + P4: 0, + unknown: 0, + }; + const actionabilityCounts = { + immediately_actionable: 0, + requires_human_input: 0, + not_actionable: 0, + unknown: 0, + }; + for (const r of reports) { + const p = r.priority; + if (p === "P0" || p === "P1" || p === "P2" || p === "P3" || p === "P4") { + priorityCounts[p] += 1; + } else { + priorityCounts.unknown += 1; + } + const a = r.actionability; + if ( + a === "immediately_actionable" || + a === "requires_human_input" || + a === "not_actionable" + ) { + actionabilityCounts[a] += 1; + } else { + actionabilityCounts.unknown += 1; + } + } track(ANALYTICS_EVENTS.INBOX_VIEWED, { report_count: reports.length, total_count: totalCount, @@ -501,11 +537,23 @@ export function InboxSignalsTab() { status_filter_count: statusFilter.length, is_empty: totalCount === 0, is_gated_due_to_scale: false, + priority_p0_count: priorityCounts.P0, + priority_p1_count: priorityCounts.P1, + priority_p2_count: priorityCounts.P2, + priority_p3_count: priorityCounts.P3, + priority_p4_count: priorityCounts.P4, + priority_unknown_count: priorityCounts.unknown, + actionability_immediately_actionable_count: + actionabilityCounts.immediately_actionable, + actionability_requires_human_input_count: + actionabilityCounts.requires_human_input, + actionability_not_actionable_count: actionabilityCounts.not_actionable, + actionability_unknown_count: actionabilityCounts.unknown, }); }, [ isInboxView, isLoading, - reports.length, + reports, totalCount, readyCount, hasActiveFilters, diff --git a/apps/code/src/renderer/features/inbox/components/InboxView.tsx b/apps/code/src/renderer/features/inbox/components/InboxView.tsx index a2ce17b62..428b3d90d 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxView.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxView.tsx @@ -33,6 +33,16 @@ export function InboxView() { status_filter_count: 0, is_empty: true, is_gated_due_to_scale: true, + priority_p0_count: 0, + priority_p1_count: 0, + priority_p2_count: 0, + priority_p3_count: 0, + priority_p4_count: 0, + priority_unknown_count: 0, + actionability_immediately_actionable_count: 0, + actionability_requires_human_input_count: 0, + actionability_not_actionable_count: 0, + actionability_unknown_count: 0, }); }, [isGatedDueToScale]); diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 5a2e0d13f..51672c9af 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -177,7 +177,13 @@ interface ReportDetailPaneProps { suppressDisabledReason: string | null; isDismissMutationPending?: boolean; onReportAction?: ( - action: Omit, + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { + priority?: string | null; + actionability?: string | null; + }, ) => void; onScroll?: () => void; } diff --git a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx index 9dc05ce1e..1304ea04d 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -63,9 +63,14 @@ interface SignalsToolbarProps { isDismissMutationPending?: boolean; /** Optional analytics callback fired when a bulk action succeeds. */ onReportAction?: ( - action: Omit & { + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { rank?: number; list_size?: number; + priority?: string | null; + actionability?: string | null; }, ) => void; } @@ -340,14 +345,36 @@ export function SignalsToolbar({ /** * Snapshot of the visible list captured at action-confirm time, so analytics - * record rank + list_size as the user saw them — not the post-mutation refetch. + * record rank/list_size/priority/actionability as the user saw them — not the + * post-mutation refetch (by then the affected reports are gone). */ + type ListSnapshotEntry = { + rank: number; + title: string | null; + createdAt: string | null; + priority: string | null; + actionability: string | null; + }; type ListSnapshot = { - rankById: Map; + byId: Map; listSize: number; }; const snapshotList = (): ListSnapshot => ({ - rankById: new Map(reports.map((r, i) => [r.id, i] as const)), + byId: new Map( + reports.map( + (r, i) => + [ + r.id, + { + rank: i, + title: r.title, + createdAt: r.created_at, + priority: r.priority ?? null, + actionability: r.actionability ?? null, + } satisfies ListSnapshotEntry, + ] as const, + ), + ), listSize: reports.length, }); @@ -358,10 +385,9 @@ export function SignalsToolbar({ ) => { if (!onReportAction) return; const isBulk = targetIds.length > 1; - const reportById = new Map(reports.map((r) => [r.id, r])); for (const reportId of targetIds) { - const target = reportById.get(reportId); - const createdAt = target?.created_at; + const entry = snapshot.byId.get(reportId); + const createdAt = entry?.createdAt; const ageMs = createdAt ? Date.now() - new Date(createdAt).getTime() : Number.NaN; @@ -370,14 +396,16 @@ export function SignalsToolbar({ : 0; onReportAction({ report_id: reportId, - report_title: target?.title ?? null, + report_title: entry?.title ?? null, report_age_hours: reportAgeHours, action_type: actionType, surface: "toolbar", is_bulk: isBulk, bulk_size: targetIds.length, - rank: snapshot.rankById.get(reportId) ?? -1, + rank: entry?.rank ?? -1, list_size: snapshot.listSize, + priority: entry?.priority ?? null, + actionability: entry?.actionability ?? null, }); } }; diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts index 819e509cc..a4de5c8f5 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts @@ -12,6 +12,8 @@ interface OpenInfo { reportId: string; reportTitle: string | null; reportCreatedAt: string | null; + reportPriority: string | null; + reportActionability: string | null; openedAt: number; rank: number; listSize: number; @@ -32,16 +34,22 @@ export interface InboxEngagementTracker { /** * Fires INBOX_REPORT_ACTION for the current open or an explicit report id. * - * `rank` and `list_size` default to the live tracker state. Callers that fire after an - * async mutation (bulk dismiss/delete/snooze/reingest, single-report dismiss confirm) - * should snapshot the pre-mutation values and pass them through — by the time the - * promise resolves the visible list has usually been re-queried without the affected - * report. + * `rank`, `list_size`, `priority`, and `actionability` default to the live tracker + * state (or a lookup in the visible list for non-current reports). Callers that fire + * after an async mutation (bulk dismiss/delete/snooze/reingest, single-report dismiss + * confirm) should snapshot the pre-mutation values and pass them through — by the + * time the promise resolves the visible list has usually been re-queried without the + * affected report. */ signalAction( - action: Omit & { + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { rank?: number; list_size?: number; + priority?: string | null; + actionability?: string | null; }, ): void; } @@ -74,6 +82,8 @@ export function useInboxEngagementTracker( report_id: info.reportId, report_title: info.reportTitle, report_age_hours: reportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, time_spent_ms: Date.now() - info.openedAt, scrolled: info.hasScrolled, close_method: closeMethod, @@ -103,6 +113,8 @@ export function useInboxEngagementTracker( reportId: currentReportId, reportTitle: report?.title ?? null, reportCreatedAt: report?.created_at ?? null, + reportPriority: report?.priority ?? null, + reportActionability: report?.actionability ?? null, openedAt: Date.now(), rank, listSize, @@ -115,7 +127,8 @@ export function useInboxEngagementTracker( report_title: info.reportTitle, report_age_hours: reportAgeHours(info.reportCreatedAt), status: report?.status ?? null, - priority: report?.priority ?? null, + priority: info.reportPriority, + actionability: info.reportActionability, source_products: report?.source_products ?? [], rank, list_size: listSize, @@ -152,6 +165,8 @@ export function useInboxEngagementTracker( report_id: info.reportId, report_title: info.reportTitle, report_age_hours: reportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, rank: info.rank, list_size: info.listSize, time_since_open_ms: Date.now() - info.openedAt, @@ -160,9 +175,14 @@ export function useInboxEngagementTracker( const signalAction = useCallback( ( - action: Omit & { + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { rank?: number; list_size?: number; + priority?: string | null; + actionability?: string | null; }, ) => { const info = openInfoRef.current; @@ -170,22 +190,47 @@ export function useInboxEngagementTracker( const { rank: rankOverride, list_size: listSizeOverride, + priority: priorityOverride, + actionability: actionabilityOverride, ...rest } = action; + // Prefer the live open-info snapshot for the current report; otherwise + // fall back to a one-shot visible-list lookup. Callers firing after an + // async mutation should pass pre-mutation overrides — by then the visible + // list has been re-queried without the affected report. + const currentInfo = + info && info.reportId === action.report_id ? info : null; + const matchedReport = currentInfo + ? null + : (visibleReports.find((r) => r.id === action.report_id) ?? null); const rank = rankOverride !== undefined ? rankOverride - : info && info.reportId === action.report_id - ? info.rank + : currentInfo + ? currentInfo.rank : visibleReports.findIndex((r) => r.id === action.report_id); const listSize = listSizeOverride !== undefined ? listSizeOverride : visibleReports.length; + const priority = + priorityOverride !== undefined + ? priorityOverride + : currentInfo + ? currentInfo.reportPriority + : (matchedReport?.priority ?? null); + const actionability = + actionabilityOverride !== undefined + ? actionabilityOverride + : currentInfo + ? currentInfo.reportActionability + : (matchedReport?.actionability ?? null); track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { ...rest, rank, list_size: listSize, + priority, + actionability, }); }, [], diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 8b62e1ccd..cc2ce6be1 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -454,6 +454,18 @@ export interface InboxViewedProperties { is_empty: boolean; /** True when the inbox is scale-gated (GatedDueToScalePane shown, data not loaded). */ is_gated_due_to_scale: boolean; + /** Breakdown of the visible report_count by priority (P0–P4, or "unknown"). */ + priority_p0_count: number; + priority_p1_count: number; + priority_p2_count: number; + priority_p3_count: number; + priority_p4_count: number; + priority_unknown_count: number; + /** Breakdown of the visible report_count by actionability. */ + actionability_immediately_actionable_count: number; + actionability_requires_human_input_count: number; + actionability_not_actionable_count: number; + actionability_unknown_count: number; } export interface InboxReportOpenedProperties { @@ -462,6 +474,7 @@ export interface InboxReportOpenedProperties { report_age_hours: number; status: string | null; priority: string | null; + actionability: string | null; source_products: string[]; rank: number; list_size: number; @@ -473,6 +486,8 @@ export interface InboxReportClosedProperties { report_id: string; report_title: string | null; report_age_hours: number; + priority: string | null; + actionability: string | null; time_spent_ms: number; scrolled: boolean; close_method: InboxReportCloseMethod; @@ -482,6 +497,8 @@ export interface InboxReportScrolledProperties { report_id: string; report_title: string | null; report_age_hours: number; + priority: string | null; + actionability: string | null; rank: number; list_size: number; time_since_open_ms: number; @@ -491,6 +508,8 @@ export interface InboxReportActionProperties { report_id: string; report_title: string | null; report_age_hours: number; + priority: string | null; + actionability: string | null; action_type: InboxReportActionType; surface: InboxReportActionSurface; is_bulk: boolean;