From 9dada4849fd20203c84bd117cdc4a54a82a4da6a Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 16 Jul 2026 11:44:59 +0100 Subject: [PATCH] feat(mobile): show custom base image badge on cloud task detail (port #3492) Ports the desktop custom base image badge to the React Native app. The task detail header now shows a violet "cube" badge with the custom sandbox base image name when a cloud run used a custom image (directly via custom_image_id or indirectly via its sandbox environment). The sandbox image and environment lists are fetched through the mobile app's authed-query pattern, and only when the latest run is a cloud run that carries a custom_image_id or sandbox_environment_id, so ordinary tasks add no extra network calls. The badge renders nothing when custom images are disabled or the image can't be resolved. Generated-By: PostHog Code Task-Id: 1230821c-af71-4788-b81f-dcaeb6442e2d --- apps/mobile/src/app/task/[id].tsx | 20 ++- apps/mobile/src/features/tasks/api.ts | 48 ++++++ .../components/CustomImageBadge.test.tsx | 147 ++++++++++++++++++ .../tasks/components/CustomImageBadge.tsx | 52 +++++++ .../tasks/hooks/useCustomImageName.ts | 55 +++++++ 5 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/CustomImageBadge.tsx create mode 100644 apps/mobile/src/features/tasks/hooks/useCustomImageName.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 1375725a49..cd0a4c4843 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -15,6 +15,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { getTask, runTaskInCloud } from "@/features/tasks/api"; +import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; @@ -667,14 +668,17 @@ export default function TaskDetailScreen() { title={showLoading ? "Loading..." : task?.title || "Task"} subtitle={task?.repository ?? undefined} rightSlot={ - canStopRun ? ( - - ) : prUrl ? ( - <> - - - - ) : null + <> + {task ? : null} + {canStopRun ? ( + + ) : prUrl ? ( + <> + + + + ) : null} + } /> diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index e2833e505e..6952fde9f6 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,4 +1,8 @@ import type { Adapter } from "@posthog/shared"; +import type { + SandboxCustomImage, + SandboxEnvironment, +} from "@posthog/shared/domain-types"; import { fetch } from "expo/fetch"; import { authedFetch, @@ -779,6 +783,50 @@ export async function streamCloudTask( }); } +export async function getSandboxCustomImages(): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + + const response = await authedFetch( + `${baseUrl}/api/projects/${projectId}/sandbox_custom_images/`, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch sandbox custom images", + ); + } + + const data = await parseJsonResponse<{ results?: SandboxCustomImage[] }>( + response, + ); + return data.results ?? []; +} + +export async function getSandboxEnvironments(): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + + const response = await authedFetch( + `${baseUrl}/api/projects/${projectId}/sandbox_environments/`, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to fetch sandbox environments", + ); + } + + const data = await parseJsonResponse<{ results?: SandboxEnvironment[] }>( + response, + ); + return data.results ?? []; +} + export async function getIntegrations(): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx new file mode 100644 index 0000000000..09b0531b6c --- /dev/null +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx @@ -0,0 +1,147 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Task, TaskRun } from "../types"; + +const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted( + () => ({ + mockUseAuthStore: vi.fn(), + mockGetImages: vi.fn(), + mockGetEnvironments: vi.fn(), + }), +); + +vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore })); + +vi.mock("../api", () => ({ + getSandboxCustomImages: mockGetImages, + getSandboxEnvironments: mockGetEnvironments, +})); + +vi.mock("phosphor-react-native", () => ({ + Cube: (props: Record) => createElement("Cube", props), +})); + +vi.mock("@/lib/theme", () => ({ + toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`, +})); + +import { CustomImageBadge } from "./CustomImageBadge"; + +function makeTask(run: Partial | undefined): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task", + description: "", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + origin_product: "user_created", + latest_run: run + ? ({ + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status: "completed", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + ...run, + } as TaskRun) + : undefined, + }; +} + +async function render(task: Task) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + let renderer: ReturnType | null = null; + await act(async () => { + renderer = create( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(CustomImageBadge, { task }), + ), + ); + }); + // Flush pending react-query resolutions so a resolved image name renders. + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + 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; +} + +describe("CustomImageBadge", () => { + beforeEach(() => { + mockUseAuthStore.mockReturnValue({ + projectId: 1, + oauthAccessToken: "token", + }); + mockGetImages.mockReset(); + mockGetEnvironments.mockReset(); + mockGetImages.mockResolvedValue([]); + mockGetEnvironments.mockResolvedValue([]); + }); + + it("renders nothing for a local run", async () => { + const r = await render( + makeTask({ environment: "local", state: { custom_image_id: "img-1" } }), + ); + expect(r.toJSON()).toBeNull(); + expect(mockGetImages).not.toHaveBeenCalled(); + }); + + it("renders nothing for a cloud run without a custom image id", async () => { + const r = await render(makeTask({ environment: "cloud", state: {} })); + expect(r.toJSON()).toBeNull(); + expect(mockGetImages).not.toHaveBeenCalled(); + }); + + it("resolves the image name from a direct custom_image_id", async () => { + mockGetImages.mockResolvedValue([{ id: "img-1", name: "My Image" }]); + const r = await render( + makeTask({ environment: "cloud", state: { custom_image_id: "img-1" } }), + ); + expect(label(r)).toBe('Runs on custom base image "My Image"'); + }); + + it("resolves the image name via sandbox_environment_id", async () => { + mockGetEnvironments.mockResolvedValue([ + { id: "env-1", custom_image_id: "img-2", custom_image_name: null }, + ]); + mockGetImages.mockResolvedValue([{ id: "img-2", name: "Env Image" }]); + const r = await render( + makeTask({ + environment: "cloud", + state: { sandbox_environment_id: "env-1" }, + }), + ); + expect(label(r)).toBe('Runs on custom base image "Env Image"'); + }); + + it("renders nothing when the image cannot be resolved", async () => { + mockGetImages.mockResolvedValue([{ id: "other", name: "Other" }]); + const r = await render( + makeTask({ environment: "cloud", state: { custom_image_id: "missing" } }), + ); + expect(r.toJSON()).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx new file mode 100644 index 0000000000..2e382c228b --- /dev/null +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx @@ -0,0 +1,52 @@ +import { Text } from "@components/text"; +import { Cube } from "phosphor-react-native"; +import { View } from "react-native"; +import { toRgba } from "@/lib/theme"; +import { useCustomImageName } from "../hooks/useCustomImageName"; +import type { Task } from "../types"; + +// Theme tokens have no violet; a fixed Radix violet-9 mirrors the desktop +// custom-image badge and reads well in both light and dark. +const VIOLET = "#6e56cf"; + +export function CustomImageBadge({ task }: { task: Task }) { + const run = task.latest_run; + const state = run?.state as + | { custom_image_id?: unknown; sandbox_environment_id?: unknown } + | undefined; + const customImageId = + typeof state?.custom_image_id === "string" ? state.custom_image_id : null; + const sandboxEnvironmentId = + typeof state?.sandbox_environment_id === "string" + ? state.sandbox_environment_id + : null; + + const imageName = useCustomImageName({ + customImageId, + sandboxEnvironmentId, + enabled: run?.environment === "cloud", + }); + + if (!imageName) return null; + + return ( + + + + {imageName} + + + ); +} diff --git a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts new file mode 100644 index 0000000000..a634ecd5e6 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/features/auth"; +import { getSandboxCustomImages, getSandboxEnvironments } from "../api"; + +export const sandboxKeys = { + customImages: () => ["sandbox-custom-images"] as const, + environments: () => ["sandbox-environments"] as const, +}; + +interface UseCustomImageNameArgs { + customImageId: string | null; + sandboxEnvironmentId: string | null; + enabled: boolean; +} + +// Returns null while loading, when the fetch fails (custom images disabled), or +// when the image can't be resolved — in every case the badge renders nothing. +export function useCustomImageName({ + customImageId, + sandboxEnvironmentId, + enabled, +}: UseCustomImageNameArgs): string | null { + const { projectId, oauthAccessToken } = useAuthStore(); + const canQuery = enabled && !!projectId && !!oauthAccessToken; + const hasImageRef = !!customImageId || !!sandboxEnvironmentId; + + const imagesQuery = useQuery({ + queryKey: sandboxKeys.customImages(), + queryFn: getSandboxCustomImages, + enabled: canQuery && hasImageRef, + staleTime: 60_000, + retry: 0, + }); + + const environmentsQuery = useQuery({ + queryKey: sandboxKeys.environments(), + queryFn: getSandboxEnvironments, + enabled: canQuery && !!sandboxEnvironmentId, + staleTime: 60_000, + retry: 0, + }); + + const environment = sandboxEnvironmentId + ? environmentsQuery.data?.find((env) => env.id === sandboxEnvironmentId) + : undefined; + + const imageId = customImageId ?? environment?.custom_image_id ?? null; + if (!imageId) return null; + + return ( + imagesQuery.data?.find((image) => image.id === imageId)?.name ?? + environment?.custom_image_name ?? + null + ); +}