Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -12,18 +12,31 @@ import {
Warning,
} from "phosphor-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 { 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 @@ -32,6 +45,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 @@ -117,6 +131,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 @@ -176,6 +243,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 @@ -185,12 +261,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],
);

if (error) {
return (
Expand Down Expand Up @@ -254,6 +359,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 @@ -331,7 +438,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 @@ -36,6 +36,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 @@ -89,6 +90,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;

// Per-task composer pill values. Persisted in taskStore so reopening the
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