Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ export default function TaskDetailScreen() {
small visual buffer at the bottom. */}
<TaskSessionView
events={session?.events ?? []}
taskId={taskId}
pendingPermissions={session?.pendingPermissions}
isConnecting={isConnecting}
isThinking={isThinking}
Expand Down
54 changes: 30 additions & 24 deletions apps/mobile/src/features/chat/components/HumanMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import { LinearGradient } from "expo-linear-gradient";
import { CaretDown, CaretUp, File as FileIcon } from "phosphor-react-native";
import { useCallback, useState } from "react";
import { type ReactNode, useCallback, useState } from "react";
import {
Alert,
type LayoutChangeEvent,
Expand All @@ -12,20 +12,38 @@ import {
} from "react-native";
import { formatRelativeTime } from "@/lib/format";
import { toRgba, useThemeColors } from "@/lib/theme";
import { MarkdownImage } from "./MarkdownImage";
import { MarkdownText } from "./MarkdownText";

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

interface HumanMessageProps {
content: string;
timestamp?: number;
attachments?: HumanMessageAttachment[];
// Lets a host (e.g. tasks) resolve cloud-backed image previews. Without one,
// attachments render as plain file chips.
renderAttachment?: (attachment: HumanMessageAttachment) => ReactNode;
}

export function MessageFileChip({ fileName }: { fileName: string }) {
const themeColors = useThemeColors();
return (
<View className="flex-row items-center gap-2 self-start rounded-md border border-gray-6 bg-gray-3 px-2 py-1.5">
<FileIcon size={14} color={themeColors.gray[11]} />
<Text className="font-mono text-[12px] text-gray-12" numberOfLines={1}>
{fileName}
</Text>
</View>
);
}

const COLLAPSED_MAX_HEIGHT = 160;
Expand All @@ -34,6 +52,7 @@ export function HumanMessage({
content,
timestamp,
attachments,
renderAttachment,
}: HumanMessageProps) {
const themeColors = useThemeColors();
const [isExpanded, setIsExpanded] = useState(false);
Expand Down Expand Up @@ -109,28 +128,15 @@ export function HumanMessage({
)}
{hasAttachments && (
<View className={hasContent ? "mt-2 gap-2" : "gap-2"}>
{attachments?.map((att) =>
att.kind === "image" ? (
<MarkdownImage
key={`${att.uri}-${att.fileName}`}
url={att.uri}
alt={att.fileName}
/>
) : (
<View
key={`${att.uri}-${att.fileName}`}
className="flex-row items-center gap-2 self-start rounded-md border border-gray-6 bg-gray-3 px-2 py-1.5"
>
<FileIcon size={14} color={themeColors.gray[11]} />
<Text
className="font-mono text-[12px] text-gray-12"
numberOfLines={1}
>
{att.fileName}
</Text>
</View>
),
)}
{attachments?.map((att) => (
<View key={`${att.uri}-${att.fileName}`}>
{renderAttachment ? (
renderAttachment(att)
) : (
<MessageFileChip fileName={att.fileName} />
)}
</View>
))}
</View>
)}
</View>
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

// Components
export { AgentMessage } from "./components/AgentMessage";
export { HumanMessage } from "./components/HumanMessage";
export {
HumanMessage,
type HumanMessageAttachment,
MessageFileChip,
} from "./components/HumanMessage";
export { MarkdownImage } from "./components/MarkdownImage";
export { MarkdownText } from "./components/MarkdownText";
export type {
ToolKind,
Expand Down
43 changes: 42 additions & 1 deletion apps/mobile/src/features/tasks/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ vi.mock("@/lib/api", () => ({
}),
}));

import { cancelRun, HttpError, runTaskInCloud } from "./api";
import {
cancelRun,
HttpError,
presignTaskRunArtifact,
runTaskInCloud,
} from "./api";

function bodyOf(call: unknown): Record<string, unknown> {
const [, init] = call as [string, RequestInit];
Expand Down Expand Up @@ -155,3 +160,39 @@ describe("cancelRun", () => {
);
});
});

describe("presignTaskRunArtifact", () => {
beforeEach(() => {
mockFetch.mockReset();
});

it("posts the storage path and returns the presigned URL", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({ url: "https://s3.example.com/x.png?sig=abc" }),
{ status: 200 },
),
);

await expect(
presignTaskRunArtifact("task-1", "run-1", "tasks/run-1/artifacts/x.png"),
).resolves.toBe("https://s3.example.com/x.png?sig=abc");

const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/artifacts/presign/",
);
expect(init.method).toBe("POST");
expect(bodyOf(mockFetch.mock.calls[0])).toEqual({
storage_path: "tasks/run-1/artifacts/x.png",
});
});

it("throws an HttpError on a non-OK response", async () => {
mockFetch.mockResolvedValue(new Response("nope", { status: 500 }));

await expect(
presignTaskRunArtifact("task-1", "run-1", "tasks/run-1/artifacts/x.png"),
).rejects.toBeInstanceOf(HttpError);
});
});
32 changes: 32 additions & 0 deletions apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,38 @@ export async function getTaskRun(
return await response.json();
}

/**
* Exchanges an artifact's storage path for a short-lived presigned S3 URL used
* to render image attachment previews.
*/
export async function presignTaskRunArtifact(
taskId: string,
runId: string,
storagePath: string,
): Promise<string> {
const baseUrl = getBaseUrl();
const projectId = getProjectId();

const response = await authedFetch(
`${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
{
method: "POST",
body: JSON.stringify({ storage_path: storagePath }),
},
);

if (!response.ok) {
throw new HttpError(
response.status,
response.statusText,
"Failed to generate artifact preview URL",
);
}

const data = (await response.json()) as { url: string };
return data.url;
}

export async function cancelRun(
taskId: string,
runId: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
type HumanMessageAttachment,
MarkdownImage,
MessageFileChip,
} from "@/features/chat";
import { useCloudAttachmentPreview } from "../hooks/useCloudAttachmentPreview";

export function CloudMessageAttachment({
attachment,
taskId,
}: {
attachment: HumanMessageAttachment;
taskId?: string;
}) {
const { data: previewUrl } = useCloudAttachmentPreview(
taskId,
attachment.cloudArtifact,
);

if (attachment.kind !== "image") {
return <MessageFileChip fileName={attachment.fileName} />;
}

// Cloud images resolve to a presigned URL; local (in-flight) images render
// straight from their device uri. Fall back to a chip when neither is ready.
const imageUrl = attachment.cloudArtifact ? previewUrl : attachment.uri;
if (!imageUrl) {
return <MessageFileChip fileName={attachment.fileName} />;
}

return <MarkdownImage url={imageUrl} alt={attachment.fileName} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ vi.mock("./PlanApprovalCard", () => ({
createElement("PlanApprovalCard", props),
}));

vi.mock("./CloudMessageAttachment", () => ({
CloudMessageAttachment: (props: Record<string, unknown>) =>
createElement("CloudMessageAttachment", props),
}));

function renderTaskSessionView(
props: Parameters<typeof TaskSessionView>[0],
): ReturnType<typeof create> {
Expand Down
18 changes: 17 additions & 1 deletion apps/mobile/src/features/tasks/components/TaskSessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
SessionNotification,
SessionNotificationAttachment,
} from "../types";
import { CloudMessageAttachment } from "./CloudMessageAttachment";
import { PlanApprovalCard } from "./PlanApprovalCard";
import { PlanStatusBar } from "./PlanStatusBar";
import { QuestionCard } from "./QuestionCard";
Expand All @@ -52,6 +53,7 @@ interface OptimisticUserMessage {

interface TaskSessionViewProps {
events: SessionEvent[];
taskId?: string;
pendingPermissions?: Record<string, CloudPendingPermissionRequest>;
isConnecting?: boolean;
isThinking?: boolean;
Expand Down Expand Up @@ -797,6 +799,7 @@ function ConnectingIndicator() {

export function TaskSessionView({
events,
taskId,
pendingPermissions,
isConnecting,
isThinking,
Expand Down Expand Up @@ -929,6 +932,13 @@ export function TaskSessionView({
[],
);

const renderAttachment = useCallback(
(attachment: SessionNotificationAttachment) => (
<CloudMessageAttachment attachment={attachment} taskId={taskId} />
),
[taskId],
);

const renderMessage = useCallback(
({ item }: { item: ParsedMessage }) => {
switch (item.type) {
Expand All @@ -938,6 +948,7 @@ export function TaskSessionView({
content={item.content}
timestamp={item.ts}
attachments={item.attachments}
renderAttachment={renderAttachment}
/>
);
case "agent":
Expand Down Expand Up @@ -994,7 +1005,12 @@ export function TaskSessionView({
return null;
}
},
[onOpenTask, onSendPermissionResponse, pendingPermissions],
[
onOpenTask,
onSendPermissionResponse,
pendingPermissions,
renderAttachment,
],
);

return (
Expand Down
47 changes: 47 additions & 0 deletions apps/mobile/src/features/tasks/hooks/useCloudAttachmentPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getProjectId } from "@/lib/api";
import { getTaskRun, presignTaskRunArtifact } from "../api";
import type { CloudArtifactRef } from "../types";

// Presigned URLs outlive this comfortably (backend issues ~1h), so we refetch
// well before expiry rather than on every render.
const PREVIEW_STALE_MS = 50 * 60 * 1000;

/**
* Resolves a cloud attachment to a presigned S3 preview URL. The run's artifact
* manifest is fetched once per run through the shared query cache, so a message
* with several images does not fire a manifest request per image. Returns
* `null` when the artifact is missing so callers can fall back to a file chip.
*/
export function useCloudAttachmentPreview(
taskId: string | undefined,
cloudArtifact: CloudArtifactRef | undefined,
) {
const queryClient = useQueryClient();
const projectId = getProjectId();

return useQuery({
queryKey: [
"cloudArtifactPreview",
projectId,
taskId,
cloudArtifact?.runId,
cloudArtifact?.artifactId,
],
enabled: Boolean(taskId && cloudArtifact),
staleTime: PREVIEW_STALE_MS,
retry: false,
queryFn: async () => {
if (!taskId || !cloudArtifact) return null;
const { runId, artifactId } = cloudArtifact;
const artifacts = await queryClient.fetchQuery({
queryKey: ["taskRunArtifacts", projectId, taskId, runId],
queryFn: async () => (await getTaskRun(taskId, runId)).artifacts ?? [],
staleTime: PREVIEW_STALE_MS,
});
const match = artifacts.find((artifact) => artifact.id === artifactId);
if (!match?.storage_path) return null;
return presignTaskRunArtifact(taskId, runId, match.storage_path);
},
});
}
Loading
Loading