From 8af7d967f2bcffe7c1084ac6b075c5ff4ea05d43 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 15:39:45 +0100 Subject: [PATCH 1/7] fix(mobile): show task PRs recorded in pr_urls (port #3873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile read a task run's PR only from `output.pr_url`, so runs that record PRs in `output.pr_urls` (the increasingly canonical field, and the only field for multi-PR runs) showed no PR link or status icon. Route all three surfaces — the task detail header, the task list PR badge, and the status-icon kind — through the shared `readPrUrls` helper so they honor both fields, matching the desktop fix. Generated-By: PostHog Code Task-Id: ffe12b00-92c7-4b99-9d63-99de687ad8ec --- apps/mobile/src/app/task/[id].tsx | 3 +- .../tasks/components/TaskItem.test.tsx | 48 +++++++++++++++---- .../features/tasks/components/TaskItem.tsx | 6 +-- .../tasks/components/TaskStatusIcon.test.ts | 26 +++++++++- .../tasks/components/taskStatusIconKind.ts | 7 +-- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 93a30c62e2..a9a32276cd 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -17,6 +17,7 @@ import { isSupportedReasoningEffort, KIMI_MODEL_FLAG, type SupportedReasoningEffort, + readPrUrls, serializeCloudPrompt, type Task, } from "@posthog/shared"; @@ -696,7 +697,7 @@ export default function TaskDetailScreen() { [router], ); - const prUrl = task?.latest_run?.output?.pr_url as string | undefined; + const prUrl = readPrUrls(task?.latest_run?.output)[0]; const activityPhase = getSessionActivityPhase({ retrying, session }); const isConnecting = activityPhase === "connecting"; diff --git a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx index 38cac02b57..6cc96a4e82 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx @@ -73,23 +73,53 @@ describe("TaskItem", () => { ); } - it("shows the PR badge with the parsed number when a PR url is present", () => { - const renderer = render( - makeTask({ - output: { pr_url: "https://github.com/PostHog/code/pull/2422" }, - }), + function badgeNumber(renderer: ReturnType, label: string) { + return renderer.root.findAll( + (node) => String(node.type) === "Text" && node.props.children === label, ); + } + + it.each([ + [ + "pr_url is set", + { pr_url: "https://github.com/PostHog/code/pull/2422" }, + "#2422", + ], + [ + "pr_urls is set", + { pr_urls: ["https://github.com/PostHog/code/pull/2422"] }, + "#2422", + ], + [ + "both fields point at the same PR", + { + pr_url: "https://github.com/PostHog/code/pull/2422", + pr_urls: ["https://github.com/PostHog/code/pull/2422"], + }, + "#2422", + ], + [ + "pr_urls lists several PRs (shows the first)", + { + pr_urls: [ + "https://github.com/PostHog/code/pull/2422", + "https://github.com/PostHog/code/pull/2423", + ], + }, + "#2422", + ], + ])("shows the PR badge when %s", (_label, output, expected) => { + const renderer = render(makeTask({ output })); expect(prIcons(renderer)).toHaveLength(1); - const number = renderer.root.findAll( - (node) => String(node.type) === "Text" && node.props.children === "#2422", - ); - expect(number).toHaveLength(1); + expect(badgeNumber(renderer, expected)).toHaveLength(1); }); it.each([ ["the task has no run", makeTask()], ["the run has no output", makeTask({ output: null })], + ["pr_urls is empty", makeTask({ output: { pr_urls: [] } })], + ["pr_url is an empty string", makeTask({ output: { pr_url: "" } })], [ "the url is a GitHub issue, not a PR", makeTask({ diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index c235722b37..7aefd69f49 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -1,5 +1,5 @@ import { Text } from "@components/text"; -import type { Task } from "@posthog/shared"; +import { type Task, readPrUrls } from "@posthog/shared"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { Check, GitPullRequest } from "phosphor-react-native"; import { memo } from "react"; @@ -46,8 +46,8 @@ function TaskItemComponent({ hoursSinceCreated < 24 ? formatDistanceToNow(createdAt, { addSuffix: true }) : format(createdAt, "MMM d"); - const prUrl = task.latest_run?.output?.pr_url; - const prRef = typeof prUrl === "string" ? parseGithubIssueUrl(prUrl) : null; + const prUrl = readPrUrls(task.latest_run?.output)[0]; + const prRef = prUrl ? parseGithubIssueUrl(prUrl) : null; return ( >): Task { } describe("getTaskStatusIconKind", () => { - it("prioritizes PR over cloud status", () => { + it.each([ + ["pr_url only", { pr_url: "https://github.com/PostHog/code/pull/1" }], + ["pr_urls only", { pr_urls: ["https://github.com/PostHog/code/pull/2"] }], + [ + "both fields", + { + pr_url: "https://github.com/PostHog/code/pull/1", + pr_urls: ["https://github.com/PostHog/code/pull/2"], + }, + ], + ])("prioritizes PR over cloud status (%s)", (_label, output) => { const task = makeTask({ environment: "cloud", status: "in_progress", - output: { pr_url: "https://github.com/PostHog/code/pull/123" }, + output, }); expect(getTaskStatusIconKind(task)).toBe("pr"); }); + it.each([ + ["output has no PR fields", { commit: "abc123" }], + ["pr_urls is empty", { pr_urls: [] }], + ["pr_url is an empty string", { pr_url: "" }], + ])("does not return pr when %s", (_label, output) => { + expect( + getTaskStatusIconKind( + makeTask({ environment: "cloud", status: "in_progress", output }), + ), + ).toBe("chat"); + }); + it("shows chat for cloud tasks without a PR, regardless of run status", () => { expect( getTaskStatusIconKind( diff --git a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts index 0fb172132b..d2ff56d8bf 100644 --- a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts +++ b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts @@ -1,4 +1,4 @@ -import type { Task } from "@posthog/shared"; +import { type Task, readPrUrls } from "@posthog/shared"; export type TaskStatusIconKind = | "pr" @@ -9,11 +9,12 @@ export type TaskStatusIconKind = | "chat"; export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { - const prUrl = task.latest_run?.output?.pr_url as string | undefined; + const hasPr = readPrUrls(task.latest_run?.output).length > 0; const status = task.latest_run?.status; const environment = task.latest_run?.environment; - if (prUrl) { + // Match desktop semantics, but let PR win when a cloud task also has one. + if (hasPr) { return "pr"; } From 8ab93ba1dc6edce926eeaa8f84b53939a54f3e73 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 15:44:55 +0100 Subject: [PATCH 2/7] fix(mobile): prepare cloud attachments before sending (port #3838) Port desktop PR #3838 to the mobile composer. Previously a cloud follow-up read and encoded every attachment at send time, so oversized/unsupported files only failed after the user hit send. - Encode each attachment's cloud-prompt block eagerly on attach via a small per-attachment preparer cache (dedupes concurrent prep, evicts failures so a re-attach retries cleanly); the send reuses the prepared block. - Show per-attachment status in the attachments bar (spinner while preparing, warning icon on failure) with accessibility labels. - Surface preparation failures immediately with an alert, and block sending while attachments are preparing or after one has failed. Generated-By: PostHog Code Task-Id: 910dc83d-955e-4514-92df-589b3c56fd20 --- .../tasks/composer/TaskChatComposer.tsx | 90 ++++++++++++++++--- .../composer/attachments/AttachmentsBar.tsx | 45 +++++++++- .../attachments/attachmentPreparer.test.ts | 78 ++++++++++++++++ .../attachments/attachmentPreparer.ts | 29 ++++++ .../composer/attachments/buildCloudPrompt.ts | 12 ++- 5 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.test.ts create mode 100644 apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.ts diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index a74bc76335..5ce3de74d6 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -24,6 +24,7 @@ import { useFeatureFlag } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, + Alert, Keyboard, Pressable, ScrollView, @@ -37,7 +38,11 @@ import { useThemeColors } from "@/lib/theme"; import type { MessagingMode } from "../stores/messagingModeStore"; import { AgentConfigControls } from "./AgentConfigControls"; import { AttachmentSheet } from "./attachments/AttachmentSheet"; -import { AttachmentsBar } from "./attachments/AttachmentsBar"; +import { + type AttachmentStatus, + AttachmentsBar, +} from "./attachments/AttachmentsBar"; +import { attachmentPreparer } from "./attachments/buildCloudPrompt"; import { captureFromCamera, pickDocument, @@ -122,6 +127,9 @@ export function TaskChatComposer({ const modelConfigOption = getModelConfigOption(configOptions); const [message, setMessage] = useState(() => initialMessage ?? ""); const [attachments, setAttachments] = useState([]); + const [attachmentStatus, setAttachmentStatus] = useState< + Record + >({}); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); // Mirror composer state into refs so a failed send can read the current @@ -132,6 +140,48 @@ export function TaskChatComposer({ attachmentsRef.current = attachments; const submissionRef = useRef(0); + const clearStatus = useCallback((id: string) => { + setAttachmentStatus((prev) => { + if (!(id in prev)) return prev; + const { [id]: _dropped, ...rest } = prev; + return rest; + }); + }, []); + + // Encode eagerly so oversized/unsupported files fail at attach, not send. + const beginPreparing = useCallback( + (att: PendingAttachment) => { + setAttachmentStatus((prev) => ({ ...prev, [att.id]: "preparing" })); + attachmentPreparer.prepare(att).then( + () => { + if (attachmentsRef.current.some((a) => a.id === att.id)) { + clearStatus(att.id); + } + }, + (error: unknown) => { + if (!attachmentsRef.current.some((a) => a.id === att.id)) return; + setAttachmentStatus((prev) => ({ ...prev, [att.id]: "error" })); + Alert.alert( + "Attachment can't be sent", + error instanceof Error + ? error.message + : "This file couldn't be prepared. Remove it and try another.", + ); + }, + ); + }, + [clearStatus], + ); + + const loadAttachments = useCallback( + (next: PendingAttachment[]) => { + setAttachments(next); + setAttachmentStatus({}); + for (const att of next) beginPreparing(att); + }, + [beginPreparing], + ); + useEffect(() => { if (!initialMessage) return; setMessage(initialMessage); @@ -140,8 +190,17 @@ export function TaskChatComposer({ useEffect(() => { if (!restoredDraft) return; setMessage(restoredDraft.text); - setAttachments(restoredDraft.attachments); - }, [restoredDraft]); + loadAttachments(restoredDraft.attachments); + }, [restoredDraft, loadAttachments]); + + useEffect( + () => () => { + for (const att of attachmentsRef.current) { + attachmentPreparer.forget(att.id); + } + }, + [], + ); useEffect(() => { if (!hasLiveConfig) return; @@ -174,9 +233,12 @@ export function TaskChatComposer({ const isTranscribing = status === "transcribing"; const hasContent = !isComposerEmpty({ text: message, attachments }); + const statuses = Object.values(attachmentStatus); + const attachmentsPreparing = statuses.includes("preparing"); + const sendBlocked = attachmentsPreparing || statuses.includes("error"); const primaryAction = resolveComposerPrimaryAction({ hasContent, - disabled, + disabled: disabled || sendBlocked, isRecording, isTranscribing, canStop: !isUserTurn && !!onStop, @@ -187,7 +249,7 @@ export function TaskChatComposer({ const applyContent = (content: ComposerContent) => { setMessage(content.text); - setAttachments(content.attachments); + loadAttachments(content.attachments); }; const handleSend = () => { @@ -214,7 +276,10 @@ export function TaskChatComposer({ ) => { try { const att = await picker(); - if (att) setAttachments((prev) => [...prev, att]); + if (att) { + setAttachments((prev) => [...prev, att]); + beginPreparing(att); + } } catch (err) { log.error("Failed to pick attachment", err); } @@ -222,6 +287,8 @@ export function TaskChatComposer({ const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((a) => a.id !== id)); + attachmentPreparer.forget(id); + clearStatus(id); }; const handleMicPress = async () => { @@ -282,6 +349,7 @@ export function TaskChatComposer({ - {isTranscribing ? ( + {isTranscribing || attachmentsPreparing ? ( - ) : canSend ? ( + ) : canSend || sendBlocked ? ( ) : isRecording || showStop ? ( diff --git a/apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx b/apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx index 146a1b5493..2fd9cfbd6c 100644 --- a/apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx +++ b/apps/mobile/src/features/tasks/composer/attachments/AttachmentsBar.tsx @@ -1,12 +1,21 @@ import { Text } from "@components/text"; -import { FileText, X } from "phosphor-react-native"; -import { Image, Pressable, ScrollView, View } from "react-native"; +import { FileText, WarningCircle, X } from "phosphor-react-native"; +import { + ActivityIndicator, + Image, + Pressable, + ScrollView, + View, +} from "react-native"; import { useThemeColors } from "@/lib/theme"; import type { PendingAttachment } from "./types"; +export type AttachmentStatus = "preparing" | "error"; + interface AttachmentsBarProps { attachments: PendingAttachment[]; onRemove: (id: string) => void; + statuses?: Record; } function truncate(name: string, max = 18): string { @@ -18,7 +27,36 @@ function truncate(name: string, max = 18): string { return `${name.slice(0, max - 1)}…`; } -export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) { +function StatusOverlay({ status }: { status?: AttachmentStatus }) { + const themeColors = useThemeColors(); + if (!status) return null; + return ( + + {status === "preparing" ? ( + + ) : ( + + )} + + ); +} + +export function AttachmentsBar({ + attachments, + onRemove, + statuses, +}: AttachmentsBarProps) { const themeColors = useThemeColors(); if (attachments.length === 0) return null; @@ -60,6 +98,7 @@ export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) { )} + onRemove(att.id)} hitSlop={8} diff --git a/apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.test.ts b/apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.test.ts new file mode 100644 index 0000000000..4a0a5ad46d --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAttachmentPreparer } from "./attachmentPreparer"; +import type { CloudPromptBlock, PendingAttachment } from "./types"; + +function attachment(id: string): PendingAttachment { + return { + kind: "document", + id, + uri: `file://${id}.txt`, + fileName: `${id}.txt`, + mimeType: "text/plain", + }; +} + +function block(id: string): CloudPromptBlock { + return { type: "text", text: id }; +} + +describe("createAttachmentPreparer", () => { + it("caches a resolved block and reuses it across prepares", async () => { + const build = vi.fn(async (att: PendingAttachment) => block(att.id)); + const preparer = createAttachmentPreparer(build); + + const first = await preparer.prepare(attachment("a")); + const second = await preparer.prepare(attachment("a")); + + expect(first).toEqual(block("a")); + expect(second).toBe(first); + expect(build).toHaveBeenCalledTimes(1); + }); + + it("dedupes concurrent preparation of the same attachment", async () => { + const build = vi.fn(async (att: PendingAttachment) => block(att.id)); + const preparer = createAttachmentPreparer(build); + + const [first, second] = await Promise.all([ + preparer.prepare(attachment("a")), + preparer.prepare(attachment("a")), + ]); + + expect(first).toBe(second); + expect(build).toHaveBeenCalledTimes(1); + }); + + it("prepares distinct attachments independently", async () => { + const build = vi.fn(async (att: PendingAttachment) => block(att.id)); + const preparer = createAttachmentPreparer(build); + + expect(await preparer.prepare(attachment("a"))).toEqual(block("a")); + expect(await preparer.prepare(attachment("b"))).toEqual(block("b")); + expect(build).toHaveBeenCalledTimes(2); + }); + + it("evicts a failed preparation so it can be retried", async () => { + const build = vi + .fn<(att: PendingAttachment) => Promise>() + .mockRejectedValueOnce(new Error("too large")) + .mockImplementation(async (att) => block(att.id)); + const preparer = createAttachmentPreparer(build); + + await expect(preparer.prepare(attachment("a"))).rejects.toThrow( + "too large", + ); + expect(await preparer.prepare(attachment("a"))).toEqual(block("a")); + expect(build).toHaveBeenCalledTimes(2); + }); + + it("re-reads after forget", async () => { + const build = vi.fn(async (att: PendingAttachment) => block(att.id)); + const preparer = createAttachmentPreparer(build); + + await preparer.prepare(attachment("a")); + preparer.forget("a"); + await preparer.prepare(attachment("a")); + + expect(build).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.ts b/apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.ts new file mode 100644 index 0000000000..118ebdb4a8 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/attachments/attachmentPreparer.ts @@ -0,0 +1,29 @@ +import type { CloudPromptBlock, PendingAttachment } from "./types"; + +export interface AttachmentPreparer { + prepare(attachment: PendingAttachment): Promise; + forget(id: string): void; +} + +export function createAttachmentPreparer( + build: (attachment: PendingAttachment) => Promise, +): AttachmentPreparer { + const cache = new Map>(); + + return { + prepare(attachment) { + const existing = cache.get(attachment.id); + if (existing) return existing; + + const pending = build(attachment).catch((error) => { + cache.delete(attachment.id); + throw error; + }); + cache.set(attachment.id, pending); + return pending; + }, + forget(id) { + cache.delete(id); + }, + }; +} diff --git a/apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts b/apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts index d5870bb1f8..2f738a17f8 100644 --- a/apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts +++ b/apps/mobile/src/features/tasks/composer/attachments/buildCloudPrompt.ts @@ -1,4 +1,5 @@ import * as FileSystem from "expo-file-system/legacy"; +import { createAttachmentPreparer } from "./attachmentPreparer"; import type { CloudPromptBlock, PendingAttachment } from "./types"; const MAX_EMBEDDED_TEXT_CHARS = 100_000; @@ -92,7 +93,9 @@ function estimateBase64Bytes(base64: string): number { return Math.floor((base64.length * 3) / 4) - padding; } -async function buildBlock(att: PendingAttachment): Promise { +export async function buildAttachmentBlock( + att: PendingAttachment, +): Promise { if (att.kind === "image") { const base64 = await FileSystem.readAsStringAsync(att.uri, { encoding: FileSystem.EncodingType.Base64, @@ -129,6 +132,9 @@ async function buildBlock(att: PendingAttachment): Promise { }; } +export const attachmentPreparer = + createAttachmentPreparer(buildAttachmentBlock); + /** * Reads each attachment from disk and assembles the cloud-prompt block array * the agent server expects. Throws if any individual attachment fails so the @@ -142,7 +148,9 @@ export async function buildCloudPromptBlocks( const trimmed = text.trim(); if (trimmed) blocks.push({ type: "text", text: trimmed }); for (const attachment of attachments) { - blocks.push(await buildBlock(attachment)); + blocks.push(await attachmentPreparer.prepare(attachment)); + // Base64/text payloads are large; release once folded into the prompt. + attachmentPreparer.forget(attachment.id); } if (blocks.length === 0) { throw new Error("Cloud prompt cannot be empty"); From 2976689a47118e53f7f34e0b9fc67d65103bf761 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 15:45:11 +0100 Subject: [PATCH 3/7] fix(mobile): restore useRef import in composer after merge The main-side `react` import (which won the non-conflicting merge) omitted `useRef`, but the ported attachment-preparation code relies on `useRef`. Without the import Biome no longer recognizes the refs as stable and flags `attachmentsRef.current` as a missing hook dependency. Restore the import. Generated-By: PostHog Code Task-Id: 00055b2d-badf-4f12-817a-f9326edaa1e4 --- apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 5ce3de74d6..8de9148624 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -21,7 +21,7 @@ import { Stop, } from "phosphor-react-native"; import { useFeatureFlag } from "posthog-react-native"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Alert, From 4d3d3ecc2f38575ced1f548dc12ada78a3dc5343 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 15:48:54 +0100 Subject: [PATCH 4/7] style(mobile): sort merged @posthog/shared imports Biome organize-imports ordering after resolving the rebase conflicts that folded readPrUrls into the existing @posthog/shared import in three task surfaces. Generated-By: PostHog Code Task-Id: 5cb4e978-4bb1-4da4-a8f5-5d2b00b7dce5 --- apps/mobile/src/app/task/[id].tsx | 2 +- apps/mobile/src/features/tasks/components/TaskItem.tsx | 2 +- apps/mobile/src/features/tasks/components/taskStatusIconKind.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index a9a32276cd..9d716c94e2 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -16,8 +16,8 @@ import { isModalModelId, isSupportedReasoningEffort, KIMI_MODEL_FLAG, - type SupportedReasoningEffort, readPrUrls, + type SupportedReasoningEffort, serializeCloudPrompt, type Task, } from "@posthog/shared"; diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index 7aefd69f49..395e055743 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -1,5 +1,5 @@ import { Text } from "@components/text"; -import { type Task, readPrUrls } from "@posthog/shared"; +import { readPrUrls, type Task } from "@posthog/shared"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { Check, GitPullRequest } from "phosphor-react-native"; import { memo } from "react"; diff --git a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts index d2ff56d8bf..cac6a7c2ac 100644 --- a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts +++ b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts @@ -1,4 +1,4 @@ -import { type Task, readPrUrls } from "@posthog/shared"; +import { readPrUrls, type Task } from "@posthog/shared"; export type TaskStatusIconKind = | "pr" From 6e60f366f9e935395185b3c48b2c467ae06a432d Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 15:50:46 +0100 Subject: [PATCH 5/7] feat(mobile): preview generated task artifacts (port #3837) Ports the desktop artifact-preview feature to mobile. A cloud run's generated output artifacts are now listed in the task session view and can be previewed in-app. - List `type === "output"` artifacts once the run reaches a terminal status, reusing the existing run-artifact manifest cache. - Tap to preview in a full-screen modal: images render natively, Markdown reuses the existing MarkdownText renderer, and HTML renders in a hardened WebView (injected CSP + JavaScript disabled + external-navigation blocked), reusing the MCP sandbox CSP helper. Anything else falls back to open-externally. - Every artifact keeps an open/share action via the system browser. Generated-By: PostHog Code Task-Id: d3b8922c-a9f2-45a2-a48c-6a05beaaf905 --- apps/mobile/src/app/task/[id].tsx | 1 + .../tasks/components/ArtifactPreview.tsx | 178 ++++++++++++++++++ .../tasks/components/TaskArtifacts.test.tsx | 65 +++++++ .../tasks/components/TaskArtifacts.tsx | 107 +++++++++++ .../tasks/components/TaskSessionView.test.tsx | 5 + .../tasks/components/TaskSessionView.tsx | 36 +++- .../features/tasks/hooks/useTaskArtifacts.ts | 31 +++ .../tasks/utils/artifactPreview.test.ts | 36 ++++ .../features/tasks/utils/artifactPreview.ts | 24 +++ 9 files changed, 474 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/ArtifactPreview.tsx create mode 100644 apps/mobile/src/features/tasks/components/TaskArtifacts.test.tsx create mode 100644 apps/mobile/src/features/tasks/components/TaskArtifacts.tsx create mode 100644 apps/mobile/src/features/tasks/hooks/useTaskArtifacts.ts create mode 100644 apps/mobile/src/features/tasks/utils/artifactPreview.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/artifactPreview.ts 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() { 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" ? ( + + + + ) : ( + { + if ( + req.url.startsWith("http://") || + req.url.startsWith("https://") + ) { + 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`; +} From a377920579add16cc23970ee4b0a132df0d4eb46 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 15:50:48 +0100 Subject: [PATCH 6/7] fix(mobile): only open artifact HTML links on a real tap The sandboxed WebView handed every http(s) navigation to the system browser, so an automatic redirect (e.g. a ) in an untrusted HTML artifact could launch an attacker URL as soon as the preview opened. Gate the external open on navigationType === "click" so only genuine taps open and automatic redirects are dropped. Generated-By: PostHog Code Task-Id: d3b8922c-a9f2-45a2-a48c-6a05beaaf905 --- .../features/tasks/components/ArtifactPreview.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx b/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx index 9512097c40..7e0f2970c4 100644 --- a/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx +++ b/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx @@ -125,10 +125,12 @@ export function ArtifactPreview({ ) 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) => { @@ -136,7 +138,7 @@ export function ArtifactPreview({ req.url.startsWith("http://") || req.url.startsWith("https://") ) { - openExternalUrl(req.url); + if (req.navigationType === "click") openExternalUrl(req.url); return false; } return true; From e81d068bbcd5e2a215a0be9f97723e9312bbc08c Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 15:50:49 +0100 Subject: [PATCH 7/7] fix(mobile): don't auto-fetch remote images in artifact previews Generated artifacts are untrusted content. A markdown artifact containing `![img](http://127.0.0.1/...)` previously caused MarkdownImage to fetch the URL via Image.getSize on mount, letting an attacker make the device issue requests to arbitrary Internet or local-network services just by having the user open the preview. Add a `disableRemoteImages` option to MarkdownText/MarkdownImage and set it for artifact previews. When enabled, remote (http/https) image URLs are not fetched automatically; they render as a tap-to-open placeholder so the request only happens on an explicit user action. Chat message rendering is unchanged. Generated-By: PostHog Code Task-Id: fbd4c004-e4af-482d-9eb1-d946a1990e44 --- .../chat/components/MarkdownImage.test.tsx | 72 +++++++++++++++++++ .../chat/components/MarkdownImage.tsx | 38 +++++++++- .../features/chat/components/MarkdownText.tsx | 17 ++++- .../tasks/components/ArtifactPreview.tsx | 2 +- 4 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/chat/components/MarkdownImage.test.tsx diff --git a/apps/mobile/src/features/chat/components/MarkdownImage.test.tsx b/apps/mobile/src/features/chat/components/MarkdownImage.test.tsx new file mode 100644 index 0000000000..6de581471f --- /dev/null +++ b/apps/mobile/src/features/chat/components/MarkdownImage.test.tsx @@ -0,0 +1,72 @@ +import { createElement } from "react"; +import { Image } from "react-native"; +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openExternalUrl } from "@/lib/openExternalUrl"; +import { MarkdownImage } from "./MarkdownImage"; + +vi.mock("@/lib/openExternalUrl", () => ({ 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 index 7e0f2970c4..1eeb2c8165 100644 --- a/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx +++ b/apps/mobile/src/features/tasks/components/ArtifactPreview.tsx @@ -119,7 +119,7 @@ export function ArtifactPreview({ className="flex-1" contentContainerStyle={{ padding: 16 }} > - + ) : (