From 5db91e55cc5b6e1f63516899485257fb0074d533 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 16 Jun 2026 11:51:39 +0100 Subject: [PATCH] feat(mobile): show live PR status on inbox report cards and detail (port #2695, #2694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbox signal reports that have an `implementation_pr_url` now render a live PR-status badge (open / merged / closed / draft) on the report list rows, the tinder card, and the report detail screen — reusing the existing `usePrStatus` hook and `PrStatusBadge` component from the tasks feature. The badge only renders for a canonical GitHub PR URL and renders nothing while the state is loading or unresolvable (private repo / 404 / unparseable), rather than showing a misleading default "open" badge. This is gated by a new `hideWhenUnresolved` prop on `PrStatusBadge`; the existing task-header usage keeps its always-tappable neutral badge. A `size="sm"` variant keeps the badge compact on dense list rows. Ports the desktop behavior from #2695 (list cards) and #2694 (detail) to the mobile app. The desktop GraphQL batching is intentionally not ported; mobile relies on react-query caching of the public GitHub REST API. Generated-By: PostHog Code Task-Id: ba8e678f-1c24-4c21-abe3-5dc204adec1a --- apps/mobile/src/app/inbox/[...id].tsx | 10 ++ .../inbox/components/ReportListRow.tsx | 11 ++ .../inbox/components/SwipeableReportCard.tsx | 10 ++ .../tasks/components/PrStatusBadge.test.tsx | 111 ++++++++++++++++++ .../tasks/components/PrStatusBadge.tsx | 20 +++- 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 9dc1c4104a..1485917540 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -57,6 +57,7 @@ import { formatSignalReportSummaryMarkdown, inboxStatusLabel, } from "@/features/inbox/utils"; +import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { computeReportAgeHours, type InboxReportActionType, @@ -472,6 +473,15 @@ export default function ReportDetailScreen() { Updated {timeDisplay} + {report.implementation_pr_url ? ( + + + + ) : null} {/* Failed warning */} diff --git a/apps/mobile/src/features/inbox/components/ReportListRow.tsx b/apps/mobile/src/features/inbox/components/ReportListRow.tsx index 65d70d5e3e..d3cdced3c6 100644 --- a/apps/mobile/src/features/inbox/components/ReportListRow.tsx +++ b/apps/mobile/src/features/inbox/components/ReportListRow.tsx @@ -2,6 +2,7 @@ import { Text } from "@components/text"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; +import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { useThemeColors } from "@/lib/theme"; import type { SignalReport } from "../types"; @@ -88,6 +89,16 @@ function ReportListRowComponent({ report, onPress }: ReportListRowProps) { + + {report.implementation_pr_url ? ( + + + + ) : null} ); } diff --git a/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx index d28ccbe0fb..b0e2dc4431 100644 --- a/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx +++ b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx @@ -5,6 +5,7 @@ import { GithubLogo, Lightning } from "phosphor-react-native"; import { useRef } from "react"; import { Animated, PanResponder, Pressable, View } from "react-native"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; +import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { useThemeColors } from "@/lib/theme"; import type { SignalReport, @@ -290,6 +291,15 @@ function CardContent({ )} + {report.implementation_pr_url ? ( + + + + ) : null} ); diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx new file mode 100644 index 0000000000..a330030fde --- /dev/null +++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx @@ -0,0 +1,111 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import type { PrStatus } from "../hooks/usePrStatus"; +import { PrStatusBadge } from "./PrStatusBadge"; + +vi.mock("phosphor-react-native", () => ({ + GitMerge: (props: Record) => + createElement("GitMerge", props), + GitPullRequest: (props: Record) => + createElement("GitPullRequest", props), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { 11: "#444444" }, + status: { success: "#00aa00", error: "#cc0000" }, + }), + toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`, +})); + +vi.mock("@/lib/openExternalUrl", () => ({ openExternalUrl: vi.fn() })); + +vi.mock("../hooks/usePrStatus", () => ({ usePrStatus: vi.fn() })); + +import { usePrStatus } from "../hooks/usePrStatus"; + +const mockUsePrStatus = vi.mocked(usePrStatus); + +function setStatus(data: PrStatus | null | undefined) { + mockUsePrStatus.mockReturnValue({ data } as ReturnType); +} + +function render(props: Parameters[0]) { + let renderer: ReturnType | null = null; + act(() => { + renderer = create(createElement(PrStatusBadge, props)); + }); + if (!renderer) throw new Error("Renderer not created"); + return renderer as ReturnType; +} + +function label(renderer: ReturnType): string | undefined { + const node = renderer.root.findAll( + (n) => typeof n.props?.accessibilityLabel === "string", + )[0]; + return node?.props.accessibilityLabel as string | undefined; +} + +function iconCount(renderer: ReturnType, type: string): number { + return renderer.root.findAll((n) => n.type === type).length; +} + +const base: PrStatus = { + state: "open", + merged: false, + draft: false, + additions: 0, + deletions: 0, +}; + +describe("PrStatusBadge", () => { + it("renders an open PR badge", () => { + setStatus({ ...base, state: "open" }); + const r = render({ prUrl: "https://github.com/a/b/pull/1" }); + expect(label(r)).toBe("Open PR"); + expect(iconCount(r, "GitPullRequest")).toBe(1); + }); + + it("renders a merged PR badge with the merge icon", () => { + setStatus({ ...base, state: "closed", merged: true }); + const r = render({ prUrl: "https://github.com/a/b/pull/1" }); + expect(label(r)).toBe("Open merged PR"); + expect(iconCount(r, "GitMerge")).toBe(1); + expect(iconCount(r, "GitPullRequest")).toBe(0); + }); + + it("renders a closed PR badge", () => { + setStatus({ ...base, state: "closed" }); + const r = render({ prUrl: "https://github.com/a/b/pull/1" }); + expect(label(r)).toBe("Open closed PR"); + }); + + it("renders a draft PR badge", () => { + setStatus({ ...base, state: "open", draft: true }); + const r = render({ prUrl: "https://github.com/a/b/pull/1" }); + expect(label(r)).toBe("Open draft PR"); + }); + + it.each([ + { data: undefined, label: "loading" }, + { data: null, label: "unresolved (private/404/non-GitHub)" }, + ])( + "renders nothing when hideWhenUnresolved is set and status is $label", + ({ data }) => { + setStatus(data); + const r = render({ + prUrl: "https://github.com/a/b/pull/1", + hideWhenUnresolved: true, + }); + expect(r.toJSON()).toBeNull(); + }, + ); + + it("still renders a neutral badge for an unresolved PR by default", () => { + setStatus(null); + const r = render({ prUrl: "https://github.com/a/b/pull/1" }); + expect(r.toJSON()).not.toBeNull(); + expect(label(r)).toBe("Open PR"); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx index f58f6376bb..646b491866 100644 --- a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx @@ -6,6 +6,11 @@ import { usePrStatus } from "../hooks/usePrStatus"; interface PrStatusBadgeProps { prUrl: string; + // Render nothing until the PR state resolves, and only for a canonical + // GitHub PR URL. Inbox surfaces use this so an always-on neutral icon never + // implies a status we couldn't confirm (private repo, 404, unparseable URL). + hideWhenUnresolved?: boolean; + size?: "sm" | "md"; } // Mirrors the desktop "merged" PR color (Radix purple-9 family). Theme tokens @@ -13,10 +18,16 @@ interface PrStatusBadgeProps { // fixed value works in both light and dark. const MERGED_COLOR = "#8e4ec6"; -export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) { +export function PrStatusBadge({ + prUrl, + hideWhenUnresolved = false, + size = "md", +}: PrStatusBadgeProps) { const themeColors = useThemeColors(); const { data: status } = usePrStatus(prUrl); + if (hideWhenUnresolved && !status) return null; + const handlePress = () => { openExternalUrl(prUrl); }; @@ -40,11 +51,14 @@ export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) { label = "Open PR"; } + const box = size === "sm" ? "h-7 w-7" : "h-9 w-9"; + const iconSize = size === "sm" ? 16 : 20; + return ( - + ); }