Skip to content

Commit afb19ea

Browse files
authored
feat(mobile): show custom base image badge on cloud task detail (port #3492) (#3499)
1 parent 9dad295 commit afb19ea

5 files changed

Lines changed: 314 additions & 8 deletions

File tree

apps/mobile/src/app/task/[id].tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
1515
import { FloatingBackButton } from "@/components/FloatingBackButton";
1616
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
1717
import { getTask, runTaskInCloud } from "@/features/tasks/api";
18+
import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge";
1819
import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader";
1920
import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge";
2021
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
@@ -667,14 +668,17 @@ export default function TaskDetailScreen() {
667668
title={showLoading ? "Loading..." : task?.title || "Task"}
668669
subtitle={task?.repository ?? undefined}
669670
rightSlot={
670-
canStopRun ? (
671-
<StopRunButton onPress={handleStopRun} />
672-
) : prUrl ? (
673-
<>
674-
<PrDiffStatsBadge prUrl={prUrl} />
675-
<PrStatusBadge prUrl={prUrl} />
676-
</>
677-
) : null
671+
<>
672+
{task ? <CustomImageBadge task={task} /> : null}
673+
{canStopRun ? (
674+
<StopRunButton onPress={handleStopRun} />
675+
) : prUrl ? (
676+
<>
677+
<PrDiffStatsBadge prUrl={prUrl} />
678+
<PrStatusBadge prUrl={prUrl} />
679+
</>
680+
) : null}
681+
</>
678682
}
679683
/>
680684
<Animated.View className="flex-1" style={contentPosition}>

apps/mobile/src/features/tasks/api.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type { Adapter } from "@posthog/shared";
2+
import type {
3+
SandboxCustomImage,
4+
SandboxEnvironment,
5+
} from "@posthog/shared/domain-types";
26
import { fetch } from "expo/fetch";
37
import {
48
authedFetch,
@@ -779,6 +783,50 @@ export async function streamCloudTask(
779783
});
780784
}
781785

786+
export async function getSandboxCustomImages(): Promise<SandboxCustomImage[]> {
787+
const baseUrl = getBaseUrl();
788+
const projectId = getProjectId();
789+
790+
const response = await authedFetch(
791+
`${baseUrl}/api/projects/${projectId}/sandbox_custom_images/`,
792+
);
793+
794+
if (!response.ok) {
795+
throw new HttpError(
796+
response.status,
797+
response.statusText,
798+
"Failed to fetch sandbox custom images",
799+
);
800+
}
801+
802+
const data = await parseJsonResponse<{ results?: SandboxCustomImage[] }>(
803+
response,
804+
);
805+
return data.results ?? [];
806+
}
807+
808+
export async function getSandboxEnvironments(): Promise<SandboxEnvironment[]> {
809+
const baseUrl = getBaseUrl();
810+
const projectId = getProjectId();
811+
812+
const response = await authedFetch(
813+
`${baseUrl}/api/projects/${projectId}/sandbox_environments/`,
814+
);
815+
816+
if (!response.ok) {
817+
throw new HttpError(
818+
response.status,
819+
response.statusText,
820+
"Failed to fetch sandbox environments",
821+
);
822+
}
823+
824+
const data = await parseJsonResponse<{ results?: SandboxEnvironment[] }>(
825+
response,
826+
);
827+
return data.results ?? [];
828+
}
829+
782830
export async function getIntegrations(): Promise<Integration[]> {
783831
const baseUrl = getBaseUrl();
784832
const projectId = getProjectId();
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { createElement } from "react";
3+
import { act, create } from "react-test-renderer";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import type { Task, TaskRun } from "../types";
6+
7+
const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted(
8+
() => ({
9+
mockUseAuthStore: vi.fn(),
10+
mockGetImages: vi.fn(),
11+
mockGetEnvironments: vi.fn(),
12+
}),
13+
);
14+
15+
vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore }));
16+
17+
vi.mock("../api", () => ({
18+
getSandboxCustomImages: mockGetImages,
19+
getSandboxEnvironments: mockGetEnvironments,
20+
}));
21+
22+
vi.mock("phosphor-react-native", () => ({
23+
Cube: (props: Record<string, unknown>) => createElement("Cube", props),
24+
}));
25+
26+
vi.mock("@/lib/theme", () => ({
27+
toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`,
28+
}));
29+
30+
import { CustomImageBadge } from "./CustomImageBadge";
31+
32+
function makeTask(run: Partial<TaskRun> | undefined): Task {
33+
return {
34+
id: "task-1",
35+
task_number: 1,
36+
slug: "task-1",
37+
title: "Task",
38+
description: "",
39+
created_at: "2026-01-01T00:00:00Z",
40+
updated_at: "2026-01-01T00:00:00Z",
41+
origin_product: "user_created",
42+
latest_run: run
43+
? ({
44+
id: "run-1",
45+
task: "task-1",
46+
team: 1,
47+
branch: null,
48+
status: "completed",
49+
log_url: "",
50+
error_message: null,
51+
output: null,
52+
state: {},
53+
created_at: "2026-01-01T00:00:00Z",
54+
updated_at: "2026-01-01T00:00:00Z",
55+
completed_at: null,
56+
...run,
57+
} as TaskRun)
58+
: undefined,
59+
};
60+
}
61+
62+
async function render(task: Task) {
63+
const queryClient = new QueryClient({
64+
defaultOptions: { queries: { retry: false, gcTime: 0 } },
65+
});
66+
let renderer: ReturnType<typeof create> | null = null;
67+
await act(async () => {
68+
renderer = create(
69+
createElement(
70+
QueryClientProvider,
71+
{ client: queryClient },
72+
createElement(CustomImageBadge, { task }),
73+
),
74+
);
75+
});
76+
// Flush pending react-query resolutions so a resolved image name renders.
77+
await act(async () => {
78+
await Promise.resolve();
79+
await new Promise((resolve) => setTimeout(resolve, 0));
80+
});
81+
if (!renderer) throw new Error("Renderer not created");
82+
return renderer as ReturnType<typeof create>;
83+
}
84+
85+
function label(renderer: ReturnType<typeof create>): string | undefined {
86+
const node = renderer.root.findAll(
87+
(n) => typeof n.props?.accessibilityLabel === "string",
88+
)[0];
89+
return node?.props.accessibilityLabel as string | undefined;
90+
}
91+
92+
describe("CustomImageBadge", () => {
93+
beforeEach(() => {
94+
mockUseAuthStore.mockReturnValue({
95+
projectId: 1,
96+
oauthAccessToken: "token",
97+
});
98+
mockGetImages.mockReset();
99+
mockGetEnvironments.mockReset();
100+
mockGetImages.mockResolvedValue([]);
101+
mockGetEnvironments.mockResolvedValue([]);
102+
});
103+
104+
it("renders nothing for a local run", async () => {
105+
const r = await render(
106+
makeTask({ environment: "local", state: { custom_image_id: "img-1" } }),
107+
);
108+
expect(r.toJSON()).toBeNull();
109+
expect(mockGetImages).not.toHaveBeenCalled();
110+
});
111+
112+
it("renders nothing for a cloud run without a custom image id", async () => {
113+
const r = await render(makeTask({ environment: "cloud", state: {} }));
114+
expect(r.toJSON()).toBeNull();
115+
expect(mockGetImages).not.toHaveBeenCalled();
116+
});
117+
118+
it("resolves the image name from a direct custom_image_id", async () => {
119+
mockGetImages.mockResolvedValue([{ id: "img-1", name: "My Image" }]);
120+
const r = await render(
121+
makeTask({ environment: "cloud", state: { custom_image_id: "img-1" } }),
122+
);
123+
expect(label(r)).toBe('Runs on custom base image "My Image"');
124+
});
125+
126+
it("resolves the image name via sandbox_environment_id", async () => {
127+
mockGetEnvironments.mockResolvedValue([
128+
{ id: "env-1", custom_image_id: "img-2", custom_image_name: null },
129+
]);
130+
mockGetImages.mockResolvedValue([{ id: "img-2", name: "Env Image" }]);
131+
const r = await render(
132+
makeTask({
133+
environment: "cloud",
134+
state: { sandbox_environment_id: "env-1" },
135+
}),
136+
);
137+
expect(label(r)).toBe('Runs on custom base image "Env Image"');
138+
});
139+
140+
it("renders nothing when the image cannot be resolved", async () => {
141+
mockGetImages.mockResolvedValue([{ id: "other", name: "Other" }]);
142+
const r = await render(
143+
makeTask({ environment: "cloud", state: { custom_image_id: "missing" } }),
144+
);
145+
expect(r.toJSON()).toBeNull();
146+
});
147+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Text } from "@components/text";
2+
import { Cube } from "phosphor-react-native";
3+
import { View } from "react-native";
4+
import { toRgba } from "@/lib/theme";
5+
import { useCustomImageName } from "../hooks/useCustomImageName";
6+
import type { Task } from "../types";
7+
8+
// Theme tokens have no violet; a fixed Radix violet-9 mirrors the desktop
9+
// custom-image badge and reads well in both light and dark.
10+
const VIOLET = "#6e56cf";
11+
12+
export function CustomImageBadge({ task }: { task: Task }) {
13+
const run = task.latest_run;
14+
const state = run?.state as
15+
| { custom_image_id?: unknown; sandbox_environment_id?: unknown }
16+
| undefined;
17+
const customImageId =
18+
typeof state?.custom_image_id === "string" ? state.custom_image_id : null;
19+
const sandboxEnvironmentId =
20+
typeof state?.sandbox_environment_id === "string"
21+
? state.sandbox_environment_id
22+
: null;
23+
24+
const imageName = useCustomImageName({
25+
customImageId,
26+
sandboxEnvironmentId,
27+
enabled: run?.environment === "cloud",
28+
});
29+
30+
if (!imageName) return null;
31+
32+
return (
33+
<View
34+
className="h-9 max-w-[160px] flex-row items-center gap-1 rounded-lg border px-2.5"
35+
style={{
36+
backgroundColor: toRgba(VIOLET, 0.12),
37+
borderColor: toRgba(VIOLET, 0.35),
38+
}}
39+
accessibilityRole="text"
40+
accessibilityLabel={`Runs on custom base image "${imageName}"`}
41+
>
42+
<Cube size={14} weight="fill" color={VIOLET} />
43+
<Text
44+
numberOfLines={1}
45+
className="font-semibold text-[13px]"
46+
style={{ color: VIOLET }}
47+
>
48+
{imageName}
49+
</Text>
50+
</View>
51+
);
52+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useAuthStore } from "@/features/auth";
3+
import { getSandboxCustomImages, getSandboxEnvironments } from "../api";
4+
5+
export const sandboxKeys = {
6+
customImages: () => ["sandbox-custom-images"] as const,
7+
environments: () => ["sandbox-environments"] as const,
8+
};
9+
10+
interface UseCustomImageNameArgs {
11+
customImageId: string | null;
12+
sandboxEnvironmentId: string | null;
13+
enabled: boolean;
14+
}
15+
16+
// Returns null while loading, when the fetch fails (custom images disabled), or
17+
// when the image can't be resolved — in every case the badge renders nothing.
18+
export function useCustomImageName({
19+
customImageId,
20+
sandboxEnvironmentId,
21+
enabled,
22+
}: UseCustomImageNameArgs): string | null {
23+
const { projectId, oauthAccessToken } = useAuthStore();
24+
const canQuery = enabled && !!projectId && !!oauthAccessToken;
25+
const hasImageRef = !!customImageId || !!sandboxEnvironmentId;
26+
27+
const imagesQuery = useQuery({
28+
queryKey: sandboxKeys.customImages(),
29+
queryFn: getSandboxCustomImages,
30+
enabled: canQuery && hasImageRef,
31+
staleTime: 60_000,
32+
retry: 0,
33+
});
34+
35+
const environmentsQuery = useQuery({
36+
queryKey: sandboxKeys.environments(),
37+
queryFn: getSandboxEnvironments,
38+
enabled: canQuery && !!sandboxEnvironmentId,
39+
staleTime: 60_000,
40+
retry: 0,
41+
});
42+
43+
const environment = sandboxEnvironmentId
44+
? environmentsQuery.data?.find((env) => env.id === sandboxEnvironmentId)
45+
: undefined;
46+
47+
const imageId = customImageId ?? environment?.custom_image_id ?? null;
48+
if (!imageId) return null;
49+
50+
return (
51+
imagesQuery.data?.find((image) => image.id === imageId)?.name ??
52+
environment?.custom_image_name ??
53+
null
54+
);
55+
}

0 commit comments

Comments
 (0)