Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit be2feba

Browse files
authored
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
1 parent a56d162 commit be2feba

10 files changed

Lines changed: 480 additions & 9 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,7 @@ export default function TaskDetailScreen() {
712712
<TaskSessionView
713713
events={session?.events ?? []}
714714
taskId={taskId}
715+
runId={task?.latest_run?.id}
715716
pendingPermissions={session?.pendingPermissions}
716717
isConnecting={isConnecting}
717718
isThinking={isThinking}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { ArrowSquareOut, Warning, X } from "phosphor-react-native";
3+
import {
4+
ActivityIndicator,
5+
Image,
6+
Modal,
7+
Pressable,
8+
ScrollView,
9+
Text,
10+
View,
11+
} from "react-native";
12+
import { useSafeAreaInsets } from "react-native-safe-area-context";
13+
import WebView from "react-native-webview";
14+
import { MarkdownText } from "@/features/chat";
15+
import { applyCspToHtml } from "@/features/mcp/sandbox/mcpAppCsp";
16+
import { openExternalUrl } from "@/lib/openExternalUrl";
17+
import { useThemeColors } from "@/lib/theme";
18+
import { useCloudAttachmentPreview } from "../hooks/useCloudAttachmentPreview";
19+
import type { TaskRunArtifact } from "../types";
20+
import { artifactPreviewKind } from "../utils/artifactPreview";
21+
22+
interface ArtifactPreviewProps {
23+
taskId: string;
24+
runId: string;
25+
artifact: TaskRunArtifact;
26+
onClose: () => void;
27+
}
28+
29+
export function ArtifactPreview({
30+
taskId,
31+
runId,
32+
artifact,
33+
onClose,
34+
}: ArtifactPreviewProps) {
35+
const insets = useSafeAreaInsets();
36+
const themeColors = useThemeColors();
37+
const name = artifact.name ?? "artifact";
38+
const kind = artifactPreviewKind(name);
39+
40+
const { data: url, isLoading: urlLoading } = useCloudAttachmentPreview(
41+
taskId,
42+
artifact.id ? { runId, artifactId: artifact.id } : undefined,
43+
);
44+
45+
// Markdown and HTML render from the file's text; images and the external
46+
// fallback only need the presigned URL.
47+
const needsText = kind === "markdown" || kind === "html";
48+
const {
49+
data: text,
50+
isLoading: textLoading,
51+
isError: textError,
52+
} = useQuery({
53+
queryKey: ["artifactText", url],
54+
enabled: needsText && Boolean(url),
55+
staleTime: Infinity,
56+
retry: false,
57+
queryFn: async (): Promise<string> => {
58+
const response = await fetch(url ?? "");
59+
if (!response.ok) throw new Error("Artifact fetch failed");
60+
return response.text();
61+
},
62+
});
63+
64+
const loading = urlLoading || (needsText && textLoading);
65+
66+
return (
67+
<Modal
68+
visible
69+
animationType="slide"
70+
presentationStyle="fullScreen"
71+
onRequestClose={onClose}
72+
>
73+
<View className="flex-1 bg-background" style={{ paddingTop: insets.top }}>
74+
<View className="flex-row items-center gap-3 px-4 pb-2">
75+
<Text
76+
className="flex-1 font-semibold text-[16px] text-gray-12"
77+
numberOfLines={1}
78+
>
79+
{name}
80+
</Text>
81+
{url ? (
82+
<Pressable
83+
onPress={() => openExternalUrl(url)}
84+
hitSlop={8}
85+
className="active:opacity-60"
86+
accessibilityLabel="Open externally"
87+
>
88+
<ArrowSquareOut size={20} color={themeColors.gray[12]} />
89+
</Pressable>
90+
) : null}
91+
<Pressable
92+
onPress={onClose}
93+
hitSlop={8}
94+
className="active:opacity-60"
95+
accessibilityLabel="Close preview"
96+
>
97+
<X size={20} color={themeColors.gray[12]} />
98+
</Pressable>
99+
</View>
100+
101+
<View className="flex-1" style={{ paddingBottom: insets.bottom }}>
102+
{loading ? (
103+
<View className="flex-1 items-center justify-center">
104+
<ActivityIndicator color={themeColors.accent[9]} />
105+
</View>
106+
) : !url || textError || kind === "unsupported" ? (
107+
<Unsupported
108+
url={url}
109+
onShare={() => url && openExternalUrl(url)}
110+
/>
111+
) : kind === "image" ? (
112+
<Image
113+
source={{ uri: url }}
114+
resizeMode="contain"
115+
style={{ flex: 1, width: "100%" }}
116+
/>
117+
) : kind === "markdown" ? (
118+
<ScrollView
119+
className="flex-1"
120+
contentContainerStyle={{ padding: 16 }}
121+
>
122+
<MarkdownText content={text ?? ""} />
123+
</ScrollView>
124+
) : (
125+
<WebView
126+
originWhitelist={["*"]}
127+
source={{ html: applyCspToHtml(text ?? "") }}
128+
// Untrusted agent output: no scripts, and any attempt to navigate
129+
// out (links, redirects) is handed to the system browser rather
130+
// than loaded inside the sandbox. The injected CSP blocks remote
131+
// resource loads on top of that.
132+
javaScriptEnabled={false}
133+
setSupportMultipleWindows={false}
134+
onShouldStartLoadWithRequest={(req) => {
135+
if (
136+
req.url.startsWith("http://") ||
137+
req.url.startsWith("https://")
138+
) {
139+
openExternalUrl(req.url);
140+
return false;
141+
}
142+
return true;
143+
}}
144+
style={{ flex: 1, backgroundColor: "#fff" }}
145+
/>
146+
)}
147+
</View>
148+
</View>
149+
</Modal>
150+
);
151+
}
152+
153+
function Unsupported({
154+
url,
155+
onShare,
156+
}: {
157+
url: string | null | undefined;
158+
onShare: () => void;
159+
}) {
160+
const themeColors = useThemeColors();
161+
return (
162+
<View className="flex-1 items-center justify-center gap-4 px-8">
163+
<Warning size={28} color={themeColors.gray[9]} />
164+
<Text className="text-center text-[14px] text-gray-11">
165+
This file can't be previewed here.
166+
</Text>
167+
{url ? (
168+
<Pressable
169+
onPress={onShare}
170+
className="flex-row items-center gap-2 rounded-lg bg-gray-3 px-4 py-2.5 active:opacity-70"
171+
>
172+
<ArrowSquareOut size={16} color={themeColors.gray[12]} />
173+
<Text className="text-[14px] text-gray-12">Open externally</Text>
174+
</Pressable>
175+
) : null}
176+
</View>
177+
);
178+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { createElement } from "react";
2+
import { act, create } from "react-test-renderer";
3+
import { describe, expect, it, vi } from "vitest";
4+
import type { TaskRunArtifact } from "../types";
5+
import { TaskArtifacts } from "./TaskArtifacts";
6+
7+
const mockUseTaskArtifacts = vi.fn();
8+
9+
vi.mock("../hooks/useTaskArtifacts", () => ({
10+
useTaskArtifacts: (...args: unknown[]) => mockUseTaskArtifacts(...args),
11+
}));
12+
13+
vi.mock("./ArtifactPreview", () => ({
14+
ArtifactPreview: (props: Record<string, unknown>) =>
15+
createElement("ArtifactPreview", props),
16+
}));
17+
18+
vi.mock("../api", () => ({ presignTaskRunArtifact: vi.fn() }));
19+
20+
vi.mock("@/lib/openExternalUrl", () => ({ openExternalUrl: vi.fn() }));
21+
22+
vi.mock("phosphor-react-native", () => ({
23+
ArrowSquareOut: (props: Record<string, unknown>) =>
24+
createElement("ArrowSquareOut", props),
25+
File: (props: Record<string, unknown>) => createElement("File", props),
26+
}));
27+
28+
vi.mock("@/lib/theme", () => ({
29+
useThemeColors: () => ({ gray: { 9: "#777", 11: "#555" } }),
30+
}));
31+
32+
function render(artifacts: TaskRunArtifact[] | undefined) {
33+
mockUseTaskArtifacts.mockReturnValue({ data: artifacts });
34+
let renderer: ReturnType<typeof create> | null = null;
35+
act(() => {
36+
renderer = create(
37+
createElement(TaskArtifacts, {
38+
taskId: "t1",
39+
runId: "r1",
40+
enabled: true,
41+
}),
42+
);
43+
});
44+
if (!renderer) throw new Error("Renderer not created");
45+
return JSON.stringify((renderer as ReturnType<typeof create>).toJSON());
46+
}
47+
48+
describe("TaskArtifacts", () => {
49+
it("renders nothing when there are no artifacts", () => {
50+
expect(render([])).toBe("null");
51+
expect(render(undefined)).toBe("null");
52+
});
53+
54+
it("lists artifact names and sizes", () => {
55+
const output = render([
56+
{ id: "a1", name: "report.md", type: "output", size: 2_400 },
57+
{ id: "a2", name: "chart.png", type: "output", size: 512 },
58+
]);
59+
expect(output).toContain("Files");
60+
expect(output).toContain("report.md");
61+
expect(output).toContain("chart.png");
62+
expect(output).toContain("2 KB");
63+
expect(output).toContain("512 B");
64+
});
65+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { ArrowSquareOut, File as FileIcon } from "phosphor-react-native";
2+
import { useCallback, useState } from "react";
3+
import { ActivityIndicator, Alert, Pressable, Text, View } from "react-native";
4+
import { openExternalUrl } from "@/lib/openExternalUrl";
5+
import { useThemeColors } from "@/lib/theme";
6+
import { presignTaskRunArtifact } from "../api";
7+
import { useTaskArtifacts } from "../hooks/useTaskArtifacts";
8+
import type { TaskRunArtifact } from "../types";
9+
import { formatArtifactSize } from "../utils/artifactPreview";
10+
import { ArtifactPreview } from "./ArtifactPreview";
11+
12+
interface TaskArtifactsProps {
13+
taskId: string | undefined;
14+
runId: string | undefined;
15+
// Gate the manifest fetch on a terminal run, mirroring desktop.
16+
enabled: boolean;
17+
}
18+
19+
export function TaskArtifacts({ taskId, runId, enabled }: TaskArtifactsProps) {
20+
const themeColors = useThemeColors();
21+
const { data: artifacts } = useTaskArtifacts(taskId, runId, enabled);
22+
const [preview, setPreview] = useState<TaskRunArtifact | null>(null);
23+
const [sharingId, setSharingId] = useState<string | null>(null);
24+
25+
const shareArtifact = useCallback(
26+
async (artifact: TaskRunArtifact): Promise<void> => {
27+
if (!taskId || !runId || !artifact.storage_path) return;
28+
setSharingId(artifact.id ?? artifact.storage_path);
29+
try {
30+
const url = await presignTaskRunArtifact(
31+
taskId,
32+
runId,
33+
artifact.storage_path,
34+
);
35+
openExternalUrl(url);
36+
} catch {
37+
Alert.alert("Couldn't open file", "Please try again.");
38+
} finally {
39+
setSharingId(null);
40+
}
41+
},
42+
[taskId, runId],
43+
);
44+
45+
if (!taskId || !runId || !artifacts || artifacts.length === 0) return null;
46+
47+
return (
48+
<View className="mx-4 mb-2 rounded-lg border border-gray-5 bg-gray-2 p-3">
49+
<Text className="mb-2 font-semibold text-[13px] text-gray-12">Files</Text>
50+
<View className="gap-1">
51+
{artifacts.map((artifact) => {
52+
const sharingKey = artifact.id ?? artifact.storage_path;
53+
const size = formatArtifactSize(artifact.size);
54+
const canPreview = Boolean(artifact.id);
55+
return (
56+
<View
57+
key={sharingKey ?? artifact.name}
58+
className="flex-row items-center gap-3 rounded-md bg-background px-2 py-1.5"
59+
>
60+
<Pressable
61+
className="min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70"
62+
disabled={!canPreview}
63+
onPress={() => setPreview(artifact)}
64+
>
65+
<FileIcon size={16} color={themeColors.gray[11]} />
66+
<Text
67+
className="flex-shrink text-[13px] text-gray-12"
68+
numberOfLines={1}
69+
>
70+
{artifact.name ?? "artifact"}
71+
</Text>
72+
{size ? (
73+
<Text className="text-[12px] text-gray-9">{size}</Text>
74+
) : null}
75+
</Pressable>
76+
<Pressable
77+
hitSlop={8}
78+
className="active:opacity-60"
79+
disabled={!artifact.storage_path}
80+
onPress={() => void shareArtifact(artifact)}
81+
accessibilityLabel="Open externally"
82+
>
83+
{sharingId === sharingKey ? (
84+
<ActivityIndicator
85+
size="small"
86+
color={themeColors.gray[11]}
87+
/>
88+
) : (
89+
<ArrowSquareOut size={16} color={themeColors.gray[11]} />
90+
)}
91+
</Pressable>
92+
</View>
93+
);
94+
})}
95+
</View>
96+
97+
{preview?.id ? (
98+
<ArtifactPreview
99+
taskId={taskId}
100+
runId={runId}
101+
artifact={preview}
102+
onClose={() => setPreview(null)}
103+
/>
104+
) : null}
105+
</View>
106+
);
107+
}

apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ vi.mock("./CloudMessageAttachment", () => ({
5656
createElement("CloudMessageAttachment", props),
5757
}));
5858

59+
vi.mock("./TaskArtifacts", () => ({
60+
TaskArtifacts: (props: Record<string, unknown>) =>
61+
createElement("TaskArtifacts", props),
62+
}));
63+
5964
function renderTaskSessionView(
6065
props: Parameters<typeof TaskSessionView>[0],
6166
): ReturnType<typeof create> {

0 commit comments

Comments
 (0)