Skip to content
Closed
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
20 changes: 12 additions & 8 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -667,14 +668,17 @@ export default function TaskDetailScreen() {
title={showLoading ? "Loading..." : task?.title || "Task"}
subtitle={task?.repository ?? undefined}
rightSlot={
canStopRun ? (
<StopRunButton onPress={handleStopRun} />
) : prUrl ? (
<>
<PrDiffStatsBadge prUrl={prUrl} />
<PrStatusBadge prUrl={prUrl} />
</>
) : null
<>
{task ? <CustomImageBadge task={task} /> : null}
{canStopRun ? (
<StopRunButton onPress={handleStopRun} />
) : prUrl ? (
<>
<PrDiffStatsBadge prUrl={prUrl} />
<PrStatusBadge prUrl={prUrl} />
</>
) : null}
</>
}
/>
<Animated.View className="flex-1" style={contentPosition}>
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -779,6 +783,50 @@ export async function streamCloudTask(
});
}

export async function getSandboxCustomImages(): Promise<SandboxCustomImage[]> {
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<SandboxEnvironment[]> {
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<Integration[]> {
const baseUrl = getBaseUrl();
const projectId = getProjectId();
Expand Down
147 changes: 147 additions & 0 deletions apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => createElement("Cube", props),
}));

vi.mock("@/lib/theme", () => ({
toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`,
}));

import { CustomImageBadge } from "./CustomImageBadge";

function makeTask(run: Partial<TaskRun> | 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<typeof create> | 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<typeof create>;
}

function label(renderer: ReturnType<typeof create>): 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();
});
});
52 changes: 52 additions & 0 deletions apps/mobile/src/features/tasks/components/CustomImageBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
className="h-9 max-w-[160px] flex-row items-center gap-1 rounded-lg border px-2.5"
style={{
backgroundColor: toRgba(VIOLET, 0.12),
borderColor: toRgba(VIOLET, 0.35),
}}
accessibilityRole="text"
accessibilityLabel={`Runs on custom base image "${imageName}"`}
>
<Cube size={14} weight="fill" color={VIOLET} />
<Text
numberOfLines={1}
className="font-semibold text-[13px]"
style={{ color: VIOLET }}
>
{imageName}
</Text>
</View>
);
}
55 changes: 55 additions & 0 deletions apps/mobile/src/features/tasks/hooks/useCustomImageName.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading
Loading