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: "",