Skip to content

Commit 49dbaf6

Browse files
authored
feat(mobile): show S3-backed image attachment thumbnails (port #3707) (#3751)
1 parent f057f43 commit 49dbaf6

14 files changed

Lines changed: 578 additions & 132 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ export default function TaskDetailScreen() {
704704
small visual buffer at the bottom. */}
705705
<TaskSessionView
706706
events={session?.events ?? []}
707+
taskId={taskId}
707708
pendingPermissions={session?.pendingPermissions}
708709
isConnecting={isConnecting}
709710
isThinking={isThinking}

apps/mobile/src/features/chat/components/HumanMessage.tsx

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Clipboard from "expo-clipboard";
22
import * as Haptics from "expo-haptics";
33
import { LinearGradient } from "expo-linear-gradient";
44
import { CaretDown, CaretUp, File as FileIcon } from "phosphor-react-native";
5-
import { useCallback, useState } from "react";
5+
import { type ReactNode, useCallback, useState } from "react";
66
import {
77
Alert,
88
type LayoutChangeEvent,
@@ -12,20 +12,38 @@ import {
1212
} from "react-native";
1313
import { formatRelativeTime } from "@/lib/format";
1414
import { toRgba, useThemeColors } from "@/lib/theme";
15-
import { MarkdownImage } from "./MarkdownImage";
1615
import { MarkdownText } from "./MarkdownText";
1716

1817
export interface HumanMessageAttachment {
1918
kind: "image" | "document";
2019
uri: string;
2120
fileName: string;
2221
mimeType?: string;
22+
// Bytes stored as a cloud run artifact rather than on this device. When set,
23+
// the preview must be resolved through a presigned URL — the raw `uri` points
24+
// at the sandbox filesystem and is not fetchable here.
25+
cloudArtifact?: { runId: string; artifactId: string };
2326
}
2427

2528
interface HumanMessageProps {
2629
content: string;
2730
timestamp?: number;
2831
attachments?: HumanMessageAttachment[];
32+
// Lets a host (e.g. tasks) resolve cloud-backed image previews. Without one,
33+
// attachments render as plain file chips.
34+
renderAttachment?: (attachment: HumanMessageAttachment) => ReactNode;
35+
}
36+
37+
export function MessageFileChip({ fileName }: { fileName: string }) {
38+
const themeColors = useThemeColors();
39+
return (
40+
<View className="flex-row items-center gap-2 self-start rounded-md border border-gray-6 bg-gray-3 px-2 py-1.5">
41+
<FileIcon size={14} color={themeColors.gray[11]} />
42+
<Text className="font-mono text-[12px] text-gray-12" numberOfLines={1}>
43+
{fileName}
44+
</Text>
45+
</View>
46+
);
2947
}
3048

3149
const COLLAPSED_MAX_HEIGHT = 160;
@@ -34,6 +52,7 @@ export function HumanMessage({
3452
content,
3553
timestamp,
3654
attachments,
55+
renderAttachment,
3756
}: HumanMessageProps) {
3857
const themeColors = useThemeColors();
3958
const [isExpanded, setIsExpanded] = useState(false);
@@ -109,28 +128,15 @@ export function HumanMessage({
109128
)}
110129
{hasAttachments && (
111130
<View className={hasContent ? "mt-2 gap-2" : "gap-2"}>
112-
{attachments?.map((att) =>
113-
att.kind === "image" ? (
114-
<MarkdownImage
115-
key={`${att.uri}-${att.fileName}`}
116-
url={att.uri}
117-
alt={att.fileName}
118-
/>
119-
) : (
120-
<View
121-
key={`${att.uri}-${att.fileName}`}
122-
className="flex-row items-center gap-2 self-start rounded-md border border-gray-6 bg-gray-3 px-2 py-1.5"
123-
>
124-
<FileIcon size={14} color={themeColors.gray[11]} />
125-
<Text
126-
className="font-mono text-[12px] text-gray-12"
127-
numberOfLines={1}
128-
>
129-
{att.fileName}
130-
</Text>
131-
</View>
132-
),
133-
)}
131+
{attachments?.map((att) => (
132+
<View key={`${att.uri}-${att.fileName}`}>
133+
{renderAttachment ? (
134+
renderAttachment(att)
135+
) : (
136+
<MessageFileChip fileName={att.fileName} />
137+
)}
138+
</View>
139+
))}
134140
</View>
135141
)}
136142
</View>

apps/mobile/src/features/chat/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55

66
// Components
77
export { AgentMessage } from "./components/AgentMessage";
8-
export { HumanMessage } from "./components/HumanMessage";
8+
export {
9+
HumanMessage,
10+
type HumanMessageAttachment,
11+
MessageFileChip,
12+
} from "./components/HumanMessage";
13+
export { MarkdownImage } from "./components/MarkdownImage";
914
export { MarkdownText } from "./components/MarkdownText";
1015
export type {
1116
ToolKind,

apps/mobile/src/features/tasks/api.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ vi.mock("@/lib/api", () => ({
2424
}),
2525
}));
2626

27-
import { cancelRun, HttpError, runTaskInCloud } from "./api";
27+
import {
28+
cancelRun,
29+
HttpError,
30+
presignTaskRunArtifact,
31+
runTaskInCloud,
32+
} from "./api";
2833

2934
function bodyOf(call: unknown): Record<string, unknown> {
3035
const [, init] = call as [string, RequestInit];
@@ -155,3 +160,39 @@ describe("cancelRun", () => {
155160
);
156161
});
157162
});
163+
164+
describe("presignTaskRunArtifact", () => {
165+
beforeEach(() => {
166+
mockFetch.mockReset();
167+
});
168+
169+
it("posts the storage path and returns the presigned URL", async () => {
170+
mockFetch.mockResolvedValue(
171+
new Response(
172+
JSON.stringify({ url: "https://s3.example.com/x.png?sig=abc" }),
173+
{ status: 200 },
174+
),
175+
);
176+
177+
await expect(
178+
presignTaskRunArtifact("task-1", "run-1", "tasks/run-1/artifacts/x.png"),
179+
).resolves.toBe("https://s3.example.com/x.png?sig=abc");
180+
181+
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
182+
expect(url).toBe(
183+
"https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/artifacts/presign/",
184+
);
185+
expect(init.method).toBe("POST");
186+
expect(bodyOf(mockFetch.mock.calls[0])).toEqual({
187+
storage_path: "tasks/run-1/artifacts/x.png",
188+
});
189+
});
190+
191+
it("throws an HttpError on a non-OK response", async () => {
192+
mockFetch.mockResolvedValue(new Response("nope", { status: 500 }));
193+
194+
await expect(
195+
presignTaskRunArtifact("task-1", "run-1", "tasks/run-1/artifacts/x.png"),
196+
).rejects.toBeInstanceOf(HttpError);
197+
});
198+
});

apps/mobile/src/features/tasks/api.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,38 @@ export async function getTaskRun(
564564
return await response.json();
565565
}
566566

567+
/**
568+
* Exchanges an artifact's storage path for a short-lived presigned S3 URL used
569+
* to render image attachment previews.
570+
*/
571+
export async function presignTaskRunArtifact(
572+
taskId: string,
573+
runId: string,
574+
storagePath: string,
575+
): Promise<string> {
576+
const baseUrl = getBaseUrl();
577+
const projectId = getProjectId();
578+
579+
const response = await authedFetch(
580+
`${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
581+
{
582+
method: "POST",
583+
body: JSON.stringify({ storage_path: storagePath }),
584+
},
585+
);
586+
587+
if (!response.ok) {
588+
throw new HttpError(
589+
response.status,
590+
response.statusText,
591+
"Failed to generate artifact preview URL",
592+
);
593+
}
594+
595+
const data = (await response.json()) as { url: string };
596+
return data.url;
597+
}
598+
567599
export async function cancelRun(
568600
taskId: string,
569601
runId: string,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
type HumanMessageAttachment,
3+
MarkdownImage,
4+
MessageFileChip,
5+
} from "@/features/chat";
6+
import { useCloudAttachmentPreview } from "../hooks/useCloudAttachmentPreview";
7+
8+
export function CloudMessageAttachment({
9+
attachment,
10+
taskId,
11+
}: {
12+
attachment: HumanMessageAttachment;
13+
taskId?: string;
14+
}) {
15+
const { data: previewUrl } = useCloudAttachmentPreview(
16+
taskId,
17+
attachment.cloudArtifact,
18+
);
19+
20+
if (attachment.kind !== "image") {
21+
return <MessageFileChip fileName={attachment.fileName} />;
22+
}
23+
24+
// Cloud images resolve to a presigned URL; local (in-flight) images render
25+
// straight from their device uri. Fall back to a chip when neither is ready.
26+
const imageUrl = attachment.cloudArtifact ? previewUrl : attachment.uri;
27+
if (!imageUrl) {
28+
return <MessageFileChip fileName={attachment.fileName} />;
29+
}
30+
31+
return <MarkdownImage url={imageUrl} alt={attachment.fileName} />;
32+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ vi.mock("./PlanApprovalCard", () => ({
5151
createElement("PlanApprovalCard", props),
5252
}));
5353

54+
vi.mock("./CloudMessageAttachment", () => ({
55+
CloudMessageAttachment: (props: Record<string, unknown>) =>
56+
createElement("CloudMessageAttachment", props),
57+
}));
58+
5459
function renderTaskSessionView(
5560
props: Parameters<typeof TaskSessionView>[0],
5661
): ReturnType<typeof create> {

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
SessionNotification,
3030
SessionNotificationAttachment,
3131
} from "../types";
32+
import { CloudMessageAttachment } from "./CloudMessageAttachment";
3233
import { PlanApprovalCard } from "./PlanApprovalCard";
3334
import { PlanStatusBar } from "./PlanStatusBar";
3435
import { QuestionCard } from "./QuestionCard";
@@ -52,6 +53,7 @@ interface OptimisticUserMessage {
5253

5354
interface TaskSessionViewProps {
5455
events: SessionEvent[];
56+
taskId?: string;
5557
pendingPermissions?: Record<string, CloudPendingPermissionRequest>;
5658
isConnecting?: boolean;
5759
isThinking?: boolean;
@@ -797,6 +799,7 @@ function ConnectingIndicator() {
797799

798800
export function TaskSessionView({
799801
events,
802+
taskId,
800803
pendingPermissions,
801804
isConnecting,
802805
isThinking,
@@ -929,6 +932,13 @@ export function TaskSessionView({
929932
[],
930933
);
931934

935+
const renderAttachment = useCallback(
936+
(attachment: SessionNotificationAttachment) => (
937+
<CloudMessageAttachment attachment={attachment} taskId={taskId} />
938+
),
939+
[taskId],
940+
);
941+
932942
const renderMessage = useCallback(
933943
({ item }: { item: ParsedMessage }) => {
934944
switch (item.type) {
@@ -938,6 +948,7 @@ export function TaskSessionView({
938948
content={item.content}
939949
timestamp={item.ts}
940950
attachments={item.attachments}
951+
renderAttachment={renderAttachment}
941952
/>
942953
);
943954
case "agent":
@@ -994,7 +1005,12 @@ export function TaskSessionView({
9941005
return null;
9951006
}
9961007
},
997-
[onOpenTask, onSendPermissionResponse, pendingPermissions],
1008+
[
1009+
onOpenTask,
1010+
onSendPermissionResponse,
1011+
pendingPermissions,
1012+
renderAttachment,
1013+
],
9981014
);
9991015

10001016
return (
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useQuery, useQueryClient } from "@tanstack/react-query";
2+
import { getProjectId } from "@/lib/api";
3+
import { getTaskRun, presignTaskRunArtifact } from "../api";
4+
import type { CloudArtifactRef } from "../types";
5+
6+
// Presigned URLs outlive this comfortably (backend issues ~1h), so we refetch
7+
// well before expiry rather than on every render.
8+
const PREVIEW_STALE_MS = 50 * 60 * 1000;
9+
10+
/**
11+
* Resolves a cloud attachment to a presigned S3 preview URL. The run's artifact
12+
* manifest is fetched once per run through the shared query cache, so a message
13+
* with several images does not fire a manifest request per image. Returns
14+
* `null` when the artifact is missing so callers can fall back to a file chip.
15+
*/
16+
export function useCloudAttachmentPreview(
17+
taskId: string | undefined,
18+
cloudArtifact: CloudArtifactRef | undefined,
19+
) {
20+
const queryClient = useQueryClient();
21+
const projectId = getProjectId();
22+
23+
return useQuery({
24+
queryKey: [
25+
"cloudArtifactPreview",
26+
projectId,
27+
taskId,
28+
cloudArtifact?.runId,
29+
cloudArtifact?.artifactId,
30+
],
31+
enabled: Boolean(taskId && cloudArtifact),
32+
staleTime: PREVIEW_STALE_MS,
33+
retry: false,
34+
queryFn: async () => {
35+
if (!taskId || !cloudArtifact) return null;
36+
const { runId, artifactId } = cloudArtifact;
37+
const artifacts = await queryClient.fetchQuery({
38+
queryKey: ["taskRunArtifacts", projectId, taskId, runId],
39+
queryFn: async () => (await getTaskRun(taskId, runId)).artifacts ?? [],
40+
staleTime: PREVIEW_STALE_MS,
41+
});
42+
const match = artifacts.find((artifact) => artifact.id === artifactId);
43+
if (!match?.storage_path) return null;
44+
return presignTaskRunArtifact(taskId, runId, match.storage_path);
45+
},
46+
});
47+
}

0 commit comments

Comments
 (0)