Skip to content
Merged
71 changes: 67 additions & 4 deletions apps/mobile/src/app/(tabs)/inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FilterSheet } from "@/features/inbox/components/FilterSheet";
Expand All @@ -13,29 +13,92 @@ import {
decidedIds,
useDismissedReportsStore,
} from "@/features/inbox/stores/dismissedReportsStore";
import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore";
import {
DEFAULT_STATUS_FILTER,
useInboxFilterStore,
} from "@/features/inbox/stores/inboxFilterStore";
import { useInboxStore } from "@/features/inbox/stores/inboxStore";
import type { SignalReport } from "@/features/inbox/types";
import { buildInboxViewedProperties } from "@/features/inbox/utils";
import { useIntegrations } from "@/features/tasks/hooks/useIntegrations";
import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics";

type InboxViewMode = "list" | "tinder";

export default function InboxScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { reports, isFetching, isLoading, error } = useInboxReports();
const { reports, totalCount, isFetching, isLoading, error } =
useInboxReports();
const [filterOpen, setFilterOpen] = useState(false);
const [reviewerOpen, setReviewerOpen] = useState(false);
const [viewMode, setViewMode] = useState<InboxViewMode>("list");
const reviewerFilterCount = useInboxFilterStore(
(s) => s.suggestedReviewerFilter.length,
);
const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter);
const statusFilter = useInboxFilterStore((s) => s.statusFilter);
const suggestedReviewerFilter = useInboxFilterStore(
(s) => s.suggestedReviewerFilter,
);

const analytics = useAnalytics();
// Fire INBOX_VIEWED once per focus when the report list has settled. We
// bump a focus counter on every focus so the useEffect re-runs even when
// the data is already cached (no loading/filter/list change to trigger it
// on its own), then guard against double-fires within the same focus via
// a ref keyed on the focus-version we last fired for.
const [focusVersion, setFocusVersion] = useState(0);
useFocusEffect(
useCallback(() => {
setFocusVersion((v) => v + 1);
}, []),
);
const viewedFiredForFocusRef = useRef<number | null>(null);
useEffect(() => {
if (focusVersion === 0) return;
if (isLoading) return;
if (viewedFiredForFocusRef.current === focusVersion) return;
viewedFiredForFocusRef.current = focusVersion;
analytics.track(
ANALYTICS_EVENTS.INBOX_VIEWED,
buildInboxViewedProperties(reports, totalCount, {
sourceProductFilter,
statusFilter,
suggestedReviewerFilter,
defaultStatusFilter: DEFAULT_STATUS_FILTER,
}),
);
}, [
analytics,
focusVersion,
isLoading,
reports,
totalCount,
sourceProductFilter,
statusFilter,
suggestedReviewerFilter,
]);

// ── Tinder mode data ──────────────────────────────────────────────────────
const decided = useDismissedReportsStore(decidedIds);
const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex);
const setLastVisibleReportIds = useInboxStore(
(s) => s.setLastVisibleReportIds,
);
const { repositoryOptions } = useIntegrations();

// Snapshot the visible-list IDs into the store so the detail screen can
// record rank/list_size on OPENED. Only the list view exposes a rank — the
// tinder card stack swaps cards in place.
useEffect(() => {
if (viewMode === "list") {
setLastVisibleReportIds(reports.map((r) => r.id));
} else {
setLastVisibleReportIds([]);
}
}, [viewMode, reports, setLastVisibleReportIds]);

// Same data as the list view, excluding already-decided reports.
const tinderReports = useMemo(
() => reports.filter((r) => !decided.includes(r.id)),
Expand Down
125 changes: 116 additions & 9 deletions apps/mobile/src/app/inbox/[...id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,32 @@ import {
} from "phosphor-react-native";
import { usePostHog } from "posthog-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import {
ActivityIndicator,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MarkdownText } from "@/features/chat/components/MarkdownText";
import { getReportRepository } from "@/features/inbox/api";
import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet";
import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet";
import {
type DismissReportResult,
DismissReportSheet,
} from "@/features/inbox/components/DismissReportSheet";
import { SignalCard } from "@/features/inbox/components/SignalCard";
import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers";
import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants";
import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker";
import {
useInboxReport,
useInboxReportArtefacts,
useInboxReportSignals,
} from "@/features/inbox/hooks/useInboxReports";
import { useInboxStore } from "@/features/inbox/stores/inboxStore";
import type {
ActionabilityJudgmentContent,
SignalFindingContent,
Expand All @@ -35,6 +48,7 @@ import type {
SuggestedReviewer,
} from "@/features/inbox/types";
import { inboxStatusLabel } from "@/features/inbox/utils";
import { computeReportAgeHours, useAnalytics } from "@/lib/analytics";
import { useThemeColors } from "@/lib/theme";

const statusColorMap: Record<string, { bg: string; text: string }> = {
Expand Down Expand Up @@ -128,6 +142,59 @@ export default function ReportDetailScreen() {
const artefactsQuery = useInboxReportArtefacts(reportId ?? null);
const signalsQuery = useInboxReportSignals(reportId ?? null);

// ── Engagement analytics ────────────────────────────────────────────────
const analytics = useAnalytics();
const lastVisibleReportIds = useInboxStore((s) => s.lastVisibleReportIds);
const previousOpenedReportId = useInboxStore((s) => s.previousOpenedReportId);
const setPreviousOpenedReportId = useInboxStore(
(s) => s.setPreviousOpenedReportId,
);
const rank = useMemo(() => {
if (!reportId) return -1;
const idx = lastVisibleReportIds.indexOf(reportId);
return idx;
}, [reportId, lastVisibleReportIds]);
const listSize = lastVisibleReportIds.length;
const tracker = useInboxEngagementTracker({
analytics,
report: report ?? null,
rank,
listSize,
openMethod: "click",
previousReportId: previousOpenedReportId,
});
// Remember this report as the "previous" once it's been opened so the next
// OPENED event can chain to it.
useEffect(() => {
if (!reportId) return;
setPreviousOpenedReportId(reportId);
}, [reportId, setPreviousOpenedReportId]);

const handleScroll = useCallback(
(_event: NativeSyntheticEvent<NativeScrollEvent>) => {
tracker.signalScroll();
},
[tracker],
);

const handleToggleSignals = useCallback(() => {
// Fire analytics outside the state updater — Strict Mode double-invokes
// updaters in development, which would double-fire the event.
const next = !signalsExpanded;
if (next && report) {
tracker.signalAction({
report_id: report.id,
report_title: report.title ?? null,
report_age_hours: computeReportAgeHours(report.created_at),
action_type: "expand_signal",
surface: "detail_pane",
is_bulk: false,
bulk_size: 1,
});
}
setSignalsExpanded(next);
}, [report, tracker, signalsExpanded]);

useEffect(() => {
if (!reportId) return;
let cancelled = false;
Expand Down Expand Up @@ -187,6 +254,15 @@ export default function ReportDetailScreen() {
const handleStartTask = useCallback(() => {
if (!report) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
tracker.signalAction({
report_id: report.id,
report_title: report.title ?? null,
report_age_hours: computeReportAgeHours(report.created_at),
action_type: "create_pr",
surface: "detail_pane",
is_bulk: false,
bulk_size: 1,
});
const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`;
router.push({
pathname: "/task",
Expand All @@ -196,12 +272,41 @@ export default function ReportDetailScreen() {
signalReport: report.id,
},
});
}, [report, router, reportRepo]);

const handleDismissed = useCallback(() => {
setDismissOpen(false);
if (router.canGoBack()) router.back();
}, [router]);
}, [report, router, reportRepo, tracker]);

const handleDismissed = useCallback(
(result: DismissReportResult) => {
setDismissOpen(false);
if (report) {
const reasonOption = DISMISSAL_REASON_OPTIONS.find(
(o) => o.value === result.reason,
);
const isSnooze =
reasonOption !== undefined &&
"snoozesInsteadOfDismiss" in reasonOption &&
reasonOption.snoozesInsteadOfDismiss === true;
tracker.signalAction({
report_id: report.id,
report_title: report.title ?? null,
report_age_hours: computeReportAgeHours(report.created_at),
action_type: isSnooze ? "snooze" : "dismiss",
surface: "detail_pane",
is_bulk: false,
bulk_size: 1,
...(isSnooze
? {}
: {
dismissal_reason: result.reason,
...(result.note
? { dismissal_note: result.note.slice(0, 1000) }
: {}),
}),
});
}
if (router.canGoBack()) router.back();
},
[router, report, tracker],
);

const handleDiscussSubmit = useCallback(
({ prompt, question }: { prompt: string; question: string }) => {
Expand Down Expand Up @@ -293,6 +398,8 @@ export default function ReportDetailScreen() {
paddingTop: 16,
paddingBottom: insets.bottom + 100,
}}
onScroll={handleScroll}
scrollEventThrottle={250}
>
{/* Badges row */}
<View className="mb-3 flex-row flex-wrap items-center gap-1.5">
Expand Down Expand Up @@ -370,7 +477,7 @@ export default function ReportDetailScreen() {
{signals.length > 0 && (
<View className="mb-4">
<Pressable
onPress={() => setSignalsExpanded((v) => !v)}
onPress={handleToggleSignals}
hitSlop={6}
accessibilityRole="button"
accessibilityState={{ expanded: signalsExpanded }}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { useTaskStore } from "@/features/tasks/stores/taskStore";
import type { Task } from "@/features/tasks/types";
import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useActiveTaskAnalyticsContext } from "@/lib/analytics";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";

Expand Down Expand Up @@ -94,6 +95,11 @@ export default function TaskDetailScreen() {
};
}, [taskId, setFocusedTaskId]);

// Tag every PostHog event fired while this task is open with the originating
// inbox report id, so a discuss-launched run can be filtered down in PostHog.
// Cleared when the screen unmounts. Matches the desktop super-property.
useActiveTaskAnalyticsContext(task?.signal_report ?? null);

const session = taskId ? getSessionForTask(taskId) : undefined;

// Optimistic echo set by the new-task screen (or the terminal-resume path
Expand Down
21 changes: 18 additions & 3 deletions apps/mobile/src/features/inbox/components/DismissReportSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,23 @@ import {
} from "../constants";
import { useDismissReport } from "../hooks/useInboxReports";

export interface DismissReportResult {
reason: DismissalReasonOptionValue;
/** Trimmed note text the user provided, if any. Empty/whitespace-only notes become null. */
note: string | null;
}

interface DismissReportSheetProps {
visible: boolean;
reportId: string;
reportTitle: string;
onClose: () => void;
onDismissed: () => void;
/**
* Fires after the API confirms the dismissal. The result is passed back so
* callers can route the reason/note through their own analytics — keeping
* this sheet stateless about the surface it was launched from.
*/
onDismissed: (result: DismissReportResult) => void;
}

export function DismissReportSheet({
Expand Down Expand Up @@ -53,10 +64,14 @@ export function DismissReportSheet({
const handleConfirm = async () => {
if (!reason || dismiss.isPending) return;
setError(null);
const trimmedNote = note.trim();
try {
await dismiss.mutateAsync({ reason, note: note.trim() || undefined });
await dismiss.mutateAsync({
reason,
note: trimmedNote || undefined,
});
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onDismissed();
onDismissed({ reason, note: trimmedNote || null });
} catch (err) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setError(
Expand Down
Loading
Loading