Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
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
67 changes: 25 additions & 42 deletions apps/mobile/src/features/tasks/components/TaskSessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ import type {
SessionEvent,
SessionNotification,
SessionNotificationAttachment,
TerminalStatus,
} from "../types";
import { CloudMessageAttachment } from "./CloudMessageAttachment";
import { PlanApprovalCard } from "./PlanApprovalCard";
import { PlanStatusBar } from "./PlanStatusBar";
import { QuestionCard } from "./QuestionCard";
import { TerminalStatusBanner } from "./TerminalStatusBanner";

interface PermissionResponseArgs {
toolCallId: string;
Expand All @@ -52,10 +55,11 @@ interface OptimisticUserMessage {

interface TaskSessionViewProps {
events: SessionEvent[];
taskId?: string;
pendingPermissions?: Record<string, CloudPendingPermissionRequest>;
isConnecting?: boolean;
isThinking?: boolean;
terminalStatus?: "failed" | "completed";
terminalStatus?: TerminalStatus;
lastError?: string | null;
onRetry?: () => void;
onOpenTask?: (taskId: string) => void;
Expand Down Expand Up @@ -797,6 +801,7 @@ function ConnectingIndicator() {

export function TaskSessionView({
events,
taskId,
pendingPermissions,
isConnecting,
isThinking,
Expand Down Expand Up @@ -929,6 +934,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 +950,7 @@ export function TaskSessionView({
content={item.content}
timestamp={item.ts}
attachments={item.attachments}
renderAttachment={renderAttachment}
/>
);
case "agent":
Expand Down Expand Up @@ -994,7 +1007,12 @@ export function TaskSessionView({
return null;
}
},
[onOpenTask, onSendPermissionResponse, pendingPermissions],
[
onOpenTask,
onSendPermissionResponse,
pendingPermissions,
renderAttachment,
],
);

return (
Expand All @@ -1017,46 +1035,11 @@ export function TaskSessionView({
initialNumToRender={30}
ListHeaderComponent={
terminalStatus ? (
<View
className={`mx-4 mt-2 mb-4 rounded-lg px-4 py-3 ${
terminalStatus === "failed"
? "bg-status-error/10"
: "bg-status-success/10"
}`}
>
<Text
className={`font-semibold text-sm ${
terminalStatus === "failed"
? "text-status-error"
: "text-status-success"
}`}
>
{terminalStatus === "failed" ? "Run failed" : "Run completed"}
</Text>
{lastError && (
<Text className="mt-1 text-gray-11 text-xs">{lastError}</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
className={`mt-2 self-start rounded-md px-3 py-1.5 ${
terminalStatus === "failed"
? "bg-status-error/20"
: "bg-status-success/20"
}`}
>
<Text
className={`font-medium text-xs ${
terminalStatus === "failed"
? "text-status-error"
: "text-status-success"
}`}
>
{terminalStatus === "failed" ? "Retry" : "Continue"}
</Text>
</Pressable>
)}
</View>
<TerminalStatusBanner
terminalStatus={terminalStatus}
lastError={lastError}
onRetry={onRetry}
/>
) : null
}
/>
Expand Down
Loading
Loading