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
+ );
+}
diff --git a/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx b/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx
index 5fff5f42c2..5f49cdc9f7 100644
--- a/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx
+++ b/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx
@@ -40,8 +40,14 @@ export function InboxSearchFilterBar({
const toggleSourceProduct = useInboxSignalsFilterStore(
(s) => s.toggleSourceProduct,
);
+ const clearSourceProductFilter = useInboxSignalsFilterStore(
+ (s) => s.clearSourceProductFilter,
+ );
const priorityFilter = useInboxSignalsFilterStore((s) => s.priorityFilter);
const togglePriority = useInboxSignalsFilterStore((s) => s.togglePriority);
+ const setPriorityFilter = useInboxSignalsFilterStore(
+ (s) => s.setPriorityFilter,
+ );
const activeSort = INBOX_SORT_OPTIONS.find(
(option) =>
@@ -73,6 +79,10 @@ export function InboxSearchFilterBar({
active={sourceProductFilter.length > 0}
>
+
{INBOX_SOURCE_OPTIONS.map((option) => {
const isActive = sourceProductFilter.includes(option.value);
return (
@@ -132,6 +142,10 @@ export function InboxSearchFilterBar({
active={priorityFilter.length > 0}
>
+ setPriorityFilter([])}
+ />
{INBOX_PRIORITY_OPTIONS.map((option) => {
const isActive = priorityFilter.includes(option.value);
return (
@@ -160,6 +174,23 @@ export function InboxSearchFilterBar({
);
}
+function InboxFilterAnyItem({
+ active,
+ onClick,
+}: {
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
function InboxFilterPopover({
label,
value,
diff --git a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts
index 64d14bd553..25bf6a9f82 100644
--- a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts
+++ b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts
@@ -71,6 +71,34 @@ describe("inboxSignalsFilterStore", () => {
]);
});
+ it("setPriorityFilter resets priorities back to Any (empty)", () => {
+ useInboxSignalsFilterStore.getState().setPriorityFilter(["P0", "P1"]);
+
+ useInboxSignalsFilterStore.getState().setPriorityFilter([]);
+
+ expect(useInboxSignalsFilterStore.getState().priorityFilter).toEqual([]);
+ });
+
+ it("clearSourceProductFilter resets sources back to Any (empty)", () => {
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("github");
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("linear");
+
+ useInboxSignalsFilterStore.getState().clearSourceProductFilter();
+
+ expect(useInboxSignalsFilterStore.getState().sourceProductFilter).toEqual(
+ [],
+ );
+ });
+
+ it("toggling off the last source is equivalent to Any (empty)", () => {
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("github");
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("github");
+
+ expect(useInboxSignalsFilterStore.getState().sourceProductFilter).toEqual(
+ [],
+ );
+ });
+
it("setPriorityFilter de-duplicates priorities", () => {
useInboxSignalsFilterStore.getState().setPriorityFilter(["P0", "P1", "P0"]);
diff --git a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts
index 33f25ef3bb..921ab26d71 100644
--- a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts
+++ b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts
@@ -31,6 +31,8 @@ interface InboxSignalsFilterActions {
toggleSourceProduct: (source: SourceProduct) => void;
togglePriority: (priority: SignalReportPriority) => void;
setPriorityFilter: (priorities: SignalReportPriority[]) => void;
+ /** Clear the source filter back to "Any" (empty = all sources). */
+ clearSourceProductFilter: () => void;
/** Reset all filters when a deep link arrives so the linked report isn't hidden. */
resetFilters: () => void;
}
@@ -74,6 +76,7 @@ export const useInboxSignalsFilterStore = create()(
set({
priorityFilter: Array.from(new Set(priorities)),
}),
+ clearSourceProductFilter: () => set({ sourceProductFilter: [] }),
resetFilters: () =>
set({
searchQuery: "",