From 938c039c5545a3ad3be25d10f41ca06188340164 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Fri, 22 May 2026 12:08:33 +0100 Subject: [PATCH 1/2] feat(code): add priority + actionability to all inbox events Slicing inbox engagement by these two fields was previously only possible on `Inbox report opened` (priority only). Now every inbox event carries the report's priority and actionability, and `Inbox viewed` carries a per-bucket count breakdown so we can analyse the visible mix in one pass. - `Inbox report opened`: add `actionability` alongside existing `priority` - `Inbox report closed`/`scrolled`/`action`: add both `priority` and `actionability`, sourced from the open-info snapshot or a visible-list lookup so call sites don't need to thread them through - `Inbox viewed`: add `priority_p0_count`..`p4_count`/`unknown_count` and `actionability_*_count` breakdowns of the visible report list --- .../inbox/components/InboxSignalsTab.tsx | 46 ++++++++++++++- .../features/inbox/components/InboxView.tsx | 10 ++++ .../components/detail/ReportDetailPane.tsx | 8 ++- .../inbox/components/list/SignalsToolbar.tsx | 7 ++- .../inbox/hooks/useInboxEngagementTracker.ts | 58 ++++++++++++++++--- apps/code/src/shared/types/analytics.ts | 19 ++++++ 6 files changed, 137 insertions(+), 11 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index d8451b7bd..63ab59b68 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -492,6 +492,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 +533,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..47a4d3e7b 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; } diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts index 819e509cc..91b85ce40 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,6 +190,8 @@ export function useInboxEngagementTracker( const { rank: rankOverride, list_size: listSizeOverride, + priority: priorityOverride, + actionability: actionabilityOverride, ...rest } = action; const rank = @@ -182,10 +204,30 @@ export function useInboxEngagementTracker( listSizeOverride !== undefined ? listSizeOverride : visibleReports.length; + // Look up the report once for priority/actionability fallbacks — prefer + // the live tracker open info, fall back to the visible list. + const matchedReport = + info && info.reportId === action.report_id + ? null + : (visibleReports.find((r) => r.id === action.report_id) ?? null); + const priority = + priorityOverride !== undefined + ? priorityOverride + : info && info.reportId === action.report_id + ? info.reportPriority + : (matchedReport?.priority ?? null); + const actionability = + actionabilityOverride !== undefined + ? actionabilityOverride + : info && info.reportId === action.report_id + ? info.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; From 652307d54e49042d248fb6ea145583e1fd74fb3f Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Fri, 22 May 2026 12:16:28 +0100 Subject: [PATCH 2/2] fix(code): snapshot priority/actionability pre-mutation for inbox actions After bulk dismiss/snooze/delete/reingest (toolbar) or single-report dismiss-confirm, the visible list has been re-queried without the affected report by the time the analytics callback fires. The tracker's visible-list fallback would have recorded `null` for the new priority/actionability fields in exactly those cases. - `SignalsToolbar`: extend `ListSnapshot` to capture priority + actionability per id (alongside rank/title/createdAt) and forward through `fireBulkAction`. - `InboxSignalsTab.handleDismissConfirm`: pass `target.priority` / `target.actionability` from the pre-mutation `allReports` lookup. - Tracker: DRY up the "current report?" check into a single `currentInfo` binding shared by rank / priority / actionability. --- .../inbox/components/InboxSignalsTab.tsx | 4 ++ .../inbox/components/list/SignalsToolbar.tsx | 39 +++++++++++++++---- .../inbox/hooks/useInboxEngagementTracker.ts | 27 +++++++------ 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 63ab59b68..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 ? {} : { 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 47a4d3e7b..1304ea04d 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -345,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, }); @@ -363,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; @@ -375,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 91b85ce40..a4de5c8f5 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts @@ -194,33 +194,36 @@ export function useInboxEngagementTracker( 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; - // Look up the report once for priority/actionability fallbacks — prefer - // the live tracker open info, fall back to the visible list. - const matchedReport = - info && info.reportId === action.report_id - ? null - : (visibleReports.find((r) => r.id === action.report_id) ?? null); const priority = priorityOverride !== undefined ? priorityOverride - : info && info.reportId === action.report_id - ? info.reportPriority + : currentInfo + ? currentInfo.reportPriority : (matchedReport?.priority ?? null); const actionability = actionabilityOverride !== undefined ? actionabilityOverride - : info && info.reportId === action.report_id - ? info.reportActionability + : currentInfo + ? currentInfo.reportActionability : (matchedReport?.actionability ?? null); track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { ...rest,