diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 93a30c62e2..37284e37db 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -795,6 +795,7 @@ export default function TaskDetailScreen() { ({ openExternalUrl: vi.fn() })); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ gray: { 9: "#777", 11: "#555" } }), +})); + +vi.mock("phosphor-react-native", () => ({ + ArrowSquareOut: (props: Record) => + createElement("ArrowSquareOut", props), + ImageBroken: (props: Record) => + createElement("ImageBroken", props), +})); + +function render(props: { + url: string; + alt?: string; + disableRemoteImages?: boolean; +}) { + let renderer: ReturnType | null = null; + act(() => { + renderer = create(createElement(MarkdownImage, props)); + }); + if (!renderer) throw new Error("Renderer not created"); + return renderer as ReturnType; +} + +describe("MarkdownImage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches image size for remote images by default", () => { + const getSize = vi.spyOn(Image, "getSize").mockImplementation(() => {}); + render({ url: "http://127.0.0.1/secret", alt: "x" }); + expect(getSize).toHaveBeenCalledWith( + "http://127.0.0.1/secret", + expect.any(Function), + expect.any(Function), + ); + }); + + it("does not fetch remote images when disableRemoteImages is set", () => { + const getSize = vi.spyOn(Image, "getSize").mockImplementation(() => {}); + const tree = JSON.stringify( + render({ + url: "http://127.0.0.1/secret", + alt: "sneaky", + disableRemoteImages: true, + }).toJSON(), + ); + expect(getSize).not.toHaveBeenCalled(); + // Renders a tap-to-open placeholder that shows the alt text. + expect(tree).toContain("sneaky"); + expect(openExternalUrl).not.toHaveBeenCalled(); + }); + + it("still fetches non-remote images even when disableRemoteImages is set", () => { + const getSize = vi.spyOn(Image, "getSize").mockImplementation(() => {}); + render({ + url: "data:image/png;base64,AAAA", + disableRemoteImages: true, + }); + expect(getSize).toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/chat/components/MarkdownImage.tsx b/apps/mobile/src/features/chat/components/MarkdownImage.tsx index f7c38661bb..55537d3023 100644 --- a/apps/mobile/src/features/chat/components/MarkdownImage.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownImage.tsx @@ -1,4 +1,4 @@ -import { ImageBroken } from "phosphor-react-native"; +import { ArrowSquareOut, ImageBroken } from "phosphor-react-native"; import { useEffect, useState } from "react"; import { ActivityIndicator, Image, Pressable, Text, View } from "react-native"; import { openExternalUrl } from "@/lib/openExternalUrl"; @@ -7,6 +7,12 @@ import { useThemeColors } from "@/lib/theme"; interface MarkdownImageProps { url: string; alt?: string; + // When true, remote (http/https) images are not fetched automatically. + // Untrusted content (e.g. a generated artifact) could otherwise make the + // device issue requests to arbitrary Internet or local-network URLs just by + // being previewed. Instead we render a placeholder that only opens the image + // externally on an explicit user tap. + disableRemoteImages?: boolean; } type LoadState = @@ -16,11 +22,21 @@ type LoadState = const MAX_HEIGHT = 320; -export function MarkdownImage({ url, alt }: MarkdownImageProps) { +function isRemoteUrl(url: string): boolean { + return url.startsWith("http://") || url.startsWith("https://"); +} + +export function MarkdownImage({ + url, + alt, + disableRemoteImages, +}: MarkdownImageProps) { const themeColors = useThemeColors(); + const deferred = Boolean(disableRemoteImages) && isRemoteUrl(url); const [state, setState] = useState({ status: "loading" }); useEffect(() => { + if (deferred) return; let cancelled = false; setState({ status: "loading" }); Image.getSize( @@ -38,7 +54,23 @@ export function MarkdownImage({ url, alt }: MarkdownImageProps) { return () => { cancelled = true; }; - }, [url]); + }, [url, deferred]); + + if (deferred) { + return ( + openExternalUrl(url)} + accessibilityRole="button" + accessibilityLabel={alt ? `Open image: ${alt}` : "Open image"} + className="flex-row items-center gap-2 rounded-md border border-gray-6 bg-gray-2 px-3 py-2 active:opacity-70" + > + + + {alt || "Tap to open image"} + + + ); + } if (state.status === "error") { return ( diff --git a/apps/mobile/src/features/chat/components/MarkdownText.tsx b/apps/mobile/src/features/chat/components/MarkdownText.tsx index cd24d734f4..4b569a357f 100644 --- a/apps/mobile/src/features/chat/components/MarkdownText.tsx +++ b/apps/mobile/src/features/chat/components/MarkdownText.tsx @@ -18,6 +18,11 @@ const BARE_POSTHOG_REF_PATTERN = interface MarkdownTextProps { content: string; + // When true, remote images embedded in the markdown are not fetched + // automatically. Set this for untrusted content (e.g. generated artifact + // previews) so opening the preview can't make the device issue requests to + // arbitrary URLs; images render as a tap-to-open placeholder instead. + disableRemoteImages?: boolean; } function HighlightedCode({ @@ -419,7 +424,10 @@ function renderInline( return nodes.length > 0 ? nodes : [text]; } -export function MarkdownText({ content }: MarkdownTextProps) { +export function MarkdownText({ + content, + disableRemoteImages, +}: MarkdownTextProps) { const blocks = parseBlocks(content); const cloudRegion = useAuthStore((state) => state.cloudRegion); const posthogUrlOptions = useMemo( @@ -612,7 +620,12 @@ export function MarkdownText({ content }: MarkdownTextProps) { case "image": return block.url ? ( - + ) : null; case "hr": diff --git a/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx b/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx new file mode 100644 index 0000000000..1eeb2c8165 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx @@ -0,0 +1,180 @@ +import type { TaskRunArtifact } from "@posthog/shared"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowSquareOut, Warning, X } from "phosphor-react-native"; +import { + ActivityIndicator, + Image, + Modal, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import WebView from "react-native-webview"; +import { MarkdownText } from "@/features/chat"; +import { applyCspToHtml } from "@/features/mcp/sandbox/mcpAppCsp"; +import { openExternalUrl } from "@/lib/openExternalUrl"; +import { useThemeColors } from "@/lib/theme"; +import { useCloudAttachmentPreview } from "../hooks/useCloudAttachmentPreview"; +import { artifactPreviewKind } from "../utils/artifactPreview"; + +interface ArtifactPreviewProps { + taskId: string; + runId: string; + artifact: TaskRunArtifact; + onClose: () => void; +} + +export function ArtifactPreview({ + taskId, + runId, + artifact, + onClose, +}: ArtifactPreviewProps) { + const insets = useSafeAreaInsets(); + const themeColors = useThemeColors(); + const name = artifact.name ?? "artifact"; + const kind = artifactPreviewKind(name); + + const { data: url, isLoading: urlLoading } = useCloudAttachmentPreview( + taskId, + artifact.id ? { runId, artifactId: artifact.id } : undefined, + ); + + // Markdown and HTML render from the file's text; images and the external + // fallback only need the presigned URL. + const needsText = kind === "markdown" || kind === "html"; + const { + data: text, + isLoading: textLoading, + isError: textError, + } = useQuery({ + queryKey: ["artifactText", url], + enabled: needsText && Boolean(url), + staleTime: Infinity, + retry: false, + queryFn: async (): Promise => { + const response = await fetch(url ?? ""); + if (!response.ok) throw new Error("Artifact fetch failed"); + return response.text(); + }, + }); + + const loading = urlLoading || (needsText && textLoading); + + return ( + + + + + {name} + + {url ? ( + openExternalUrl(url)} + hitSlop={8} + className="active:opacity-60" + accessibilityLabel="Open externally" + > + + + ) : null} + + + + + + + {loading ? ( + + + + ) : !url || textError || kind === "unsupported" ? ( + url && openExternalUrl(url)} + /> + ) : kind === "image" ? ( + + ) : kind === "markdown" ? ( + + + + ) : ( + ) are dropped so previewing a file can't silently + // launch an attacker URL. The injected CSP blocks remote resource + // loads on top of that. + javaScriptEnabled={false} + setSupportMultipleWindows={false} + onShouldStartLoadWithRequest={(req) => { + if ( + req.url.startsWith("http://") || + req.url.startsWith("https://") + ) { + if (req.navigationType === "click") openExternalUrl(req.url); + return false; + } + return true; + }} + style={{ flex: 1, backgroundColor: "#fff" }} + /> + )} + + + + ); +} + +function Unsupported({ + url, + onShare, +}: { + url: string | null | undefined; + onShare: () => void; +}) { + const themeColors = useThemeColors(); + return ( + + + + This file can't be previewed here. + + {url ? ( + + + Open externally + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskArtifacts.test.tsx b/apps/mobile/src/features/tasks/components/TaskArtifacts.test.tsx new file mode 100644 index 0000000000..5f9c6f71ba --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskArtifacts.test.tsx @@ -0,0 +1,65 @@ +import type { TaskRunArtifact } from "@posthog/shared"; +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { TaskArtifacts } from "./TaskArtifacts"; + +const mockUseTaskArtifacts = vi.fn(); + +vi.mock("../hooks/useTaskArtifacts", () => ({ + useTaskArtifacts: (...args: unknown[]) => mockUseTaskArtifacts(...args), +})); + +vi.mock("./ArtifactPreview", () => ({ + ArtifactPreview: (props: Record) => + createElement("ArtifactPreview", props), +})); + +vi.mock("../api", () => ({ presignTaskRunArtifact: vi.fn() })); + +vi.mock("@/lib/openExternalUrl", () => ({ openExternalUrl: vi.fn() })); + +vi.mock("phosphor-react-native", () => ({ + ArrowSquareOut: (props: Record) => + createElement("ArrowSquareOut", props), + File: (props: Record) => createElement("File", props), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ gray: { 9: "#777", 11: "#555" } }), +})); + +function render(artifacts: TaskRunArtifact[] | undefined) { + mockUseTaskArtifacts.mockReturnValue({ data: artifacts }); + let renderer: ReturnType | null = null; + act(() => { + renderer = create( + createElement(TaskArtifacts, { + taskId: "t1", + runId: "r1", + enabled: true, + }), + ); + }); + if (!renderer) throw new Error("Renderer not created"); + return JSON.stringify((renderer as ReturnType).toJSON()); +} + +describe("TaskArtifacts", () => { + it("renders nothing when there are no artifacts", () => { + expect(render([])).toBe("null"); + expect(render(undefined)).toBe("null"); + }); + + it("lists artifact names and sizes", () => { + const output = render([ + { id: "a1", name: "report.md", type: "output", size: 2_400 }, + { id: "a2", name: "chart.png", type: "output", size: 512 }, + ]); + expect(output).toContain("Files"); + expect(output).toContain("report.md"); + expect(output).toContain("chart.png"); + expect(output).toContain("2 KB"); + expect(output).toContain("512 B"); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/TaskArtifacts.tsx b/apps/mobile/src/features/tasks/components/TaskArtifacts.tsx new file mode 100644 index 0000000000..b4c9268fca --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskArtifacts.tsx @@ -0,0 +1,107 @@ +import type { TaskRunArtifact } from "@posthog/shared"; +import { ArrowSquareOut, File as FileIcon } from "phosphor-react-native"; +import { useCallback, useState } from "react"; +import { ActivityIndicator, Alert, Pressable, Text, View } from "react-native"; +import { openExternalUrl } from "@/lib/openExternalUrl"; +import { useThemeColors } from "@/lib/theme"; +import { presignTaskRunArtifact } from "../api"; +import { useTaskArtifacts } from "../hooks/useTaskArtifacts"; +import { formatArtifactSize } from "../utils/artifactPreview"; +import { ArtifactPreview } from "./ArtifactPreview"; + +interface TaskArtifactsProps { + taskId: string | undefined; + runId: string | undefined; + // Gate the manifest fetch on a terminal run, mirroring desktop. + enabled: boolean; +} + +export function TaskArtifacts({ taskId, runId, enabled }: TaskArtifactsProps) { + const themeColors = useThemeColors(); + const { data: artifacts } = useTaskArtifacts(taskId, runId, enabled); + const [preview, setPreview] = useState(null); + const [sharingId, setSharingId] = useState(null); + + const shareArtifact = useCallback( + async (artifact: TaskRunArtifact): Promise => { + if (!taskId || !runId || !artifact.storage_path) return; + setSharingId(artifact.id ?? artifact.storage_path); + try { + const url = await presignTaskRunArtifact( + taskId, + runId, + artifact.storage_path, + ); + openExternalUrl(url); + } catch { + Alert.alert("Couldn't open file", "Please try again."); + } finally { + setSharingId(null); + } + }, + [taskId, runId], + ); + + if (!taskId || !runId || !artifacts || artifacts.length === 0) return null; + + return ( + + Files + + {artifacts.map((artifact) => { + const sharingKey = artifact.id ?? artifact.storage_path; + const size = formatArtifactSize(artifact.size); + const canPreview = Boolean(artifact.id); + return ( + + setPreview(artifact)} + > + + + {artifact.name ?? "artifact"} + + {size ? ( + {size} + ) : null} + + void shareArtifact(artifact)} + accessibilityLabel="Open externally" + > + {sharingId === sharingKey ? ( + + ) : ( + + )} + + + ); + })} + + + {preview?.id ? ( + setPreview(null)} + /> + ) : null} + + ); +} diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx index e55c5f0f72..1490ad19d7 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx @@ -56,6 +56,11 @@ vi.mock("./CloudMessageAttachment", () => ({ createElement("CloudMessageAttachment", props), })); +vi.mock("./TaskArtifacts", () => ({ + TaskArtifacts: (props: Record) => + createElement("TaskArtifacts", props), +})); + function renderTaskSessionView( props: Parameters[0], ): ReturnType { diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 75a06938b0..d9d89babaa 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -34,6 +34,7 @@ import { CloudMessageAttachment } from "./CloudMessageAttachment"; import { PlanApprovalCard } from "./PlanApprovalCard"; import { PlanStatusBar } from "./PlanStatusBar"; import { QuestionCard } from "./QuestionCard"; +import { TaskArtifacts } from "./TaskArtifacts"; import { TerminalStatusBanner } from "./TerminalStatusBanner"; interface PermissionResponseArgs { @@ -56,6 +57,8 @@ interface OptimisticUserMessage { interface TaskSessionViewProps { events: SessionEvent[]; taskId?: string; + // Latest run id, used to list the run's generated output artifacts. + runId?: string; pendingPermissions?: Record; isConnecting?: boolean; isThinking?: boolean; @@ -804,6 +807,7 @@ function ConnectingIndicator() { export function TaskSessionView({ events, taskId, + runId, pendingPermissions, isConnecting, isThinking, @@ -1017,6 +1021,28 @@ export function TaskSessionView({ ], ); + // Memoized so a live session's frequent re-renders (streaming, timers) don't + // reconcile the banner + artifacts subtree on every tick. + const listHeader = useMemo( + () => ( + <> + {terminalStatus ? ( + + ) : null} + + + ), + [terminalStatus, lastError, onRetry, taskId, runId], + ); + return ( @@ -1035,15 +1061,7 @@ export function TaskSessionView({ maxToRenderPerBatch={15} windowSize={21} initialNumToRender={30} - ListHeaderComponent={ - terminalStatus ? ( - - ) : null - } + ListHeaderComponent={listHeader} /> {/* Thinking/connecting indicators pinned to the bottom of the list area. The Composer is a sibling below TaskSessionView in flex flow, so diff --git a/apps/mobile/src/features/tasks/hooks/useTaskArtifacts.ts b/apps/mobile/src/features/tasks/hooks/useTaskArtifacts.ts new file mode 100644 index 0000000000..9a6f655bf4 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useTaskArtifacts.ts @@ -0,0 +1,31 @@ +import type { TaskRunArtifact } from "@posthog/shared"; +import { useQuery } from "@tanstack/react-query"; +import { getProjectId } from "@/lib/api"; +import { getTaskRun } from "../api"; + +// Matches the manifest staleness used for attachment previews so both share the +// same ["taskRunArtifacts", …] cache entry and refetch on the same schedule. +const ARTIFACTS_STALE_MS = 50 * 60 * 1000; + +/** + * Lists a run's generated output artifacts. `enabled` should gate on a terminal + * run status — mirrors desktop, which only fetches the manifest once the run + * has finished producing files. + */ +export function useTaskArtifacts( + taskId: string | undefined, + runId: string | undefined, + enabled: boolean, +) { + const projectId = getProjectId(); + + return useQuery({ + queryKey: ["taskRunArtifacts", projectId, taskId, runId], + enabled: enabled && Boolean(taskId && runId), + staleTime: ARTIFACTS_STALE_MS, + retry: false, + queryFn: async (): Promise => + (await getTaskRun(taskId ?? "", runId ?? "")).artifacts ?? [], + select: (artifacts) => artifacts.filter((a) => a.type === "output"), + }); +} diff --git a/apps/mobile/src/features/tasks/utils/artifactPreview.test.ts b/apps/mobile/src/features/tasks/utils/artifactPreview.test.ts new file mode 100644 index 0000000000..09df3413b1 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/artifactPreview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + type ArtifactPreviewKind, + artifactPreviewKind, + formatArtifactSize, +} from "./artifactPreview"; + +describe("artifactPreviewKind", () => { + it.each<[string, ArtifactPreviewKind]>([ + ["chart.png", "image"], + ["photo.JPEG", "image"], + ["diagram.webp", "image"], + ["report.md", "markdown"], + ["notes.markdown", "markdown"], + ["doc.MDX", "markdown"], + ["page.html", "html"], + ["page.htm", "html"], + ["data.csv", "unsupported"], + ["archive.zip", "unsupported"], + ["noextension", "unsupported"], + ])("maps %s to %s", (fileName, expected) => { + expect(artifactPreviewKind(fileName)).toBe(expected); + }); +}); + +describe("formatArtifactSize", () => { + it.each<[number | undefined, string | null]>([ + [undefined, null], + [0, "0 B"], + [512, "512 B"], + [1_500, "2 KB"], + [2_400_000, "2.4 MB"], + ])("formats %s as %s", (size, expected) => { + expect(formatArtifactSize(size)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/artifactPreview.ts b/apps/mobile/src/features/tasks/utils/artifactPreview.ts new file mode 100644 index 0000000000..8f52babca6 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/artifactPreview.ts @@ -0,0 +1,24 @@ +import { isRasterImageFile } from "@posthog/shared"; + +export type ArtifactPreviewKind = "image" | "markdown" | "html" | "unsupported"; + +const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]); + +function extension(fileName: string): string { + return fileName.split(".").pop()?.toLowerCase() ?? ""; +} + +export function artifactPreviewKind(fileName: string): ArtifactPreviewKind { + if (isRasterImageFile(fileName)) return "image"; + const ext = extension(fileName); + if (MARKDOWN_EXTENSIONS.has(ext)) return "markdown"; + if (ext === "html" || ext === "htm") return "html"; + return "unsupported"; +} + +export function formatArtifactSize(size: number | undefined): string | null { + if (size === undefined) return null; + if (size < 1_000) return `${size} B`; + if (size < 1_000_000) return `${Math.round(size / 1_000)} KB`; + return `${(size / 1_000_000).toFixed(1)} MB`; +}