>;
+ try {
+ result = await fetchTextAttachmentContent(fileId, controller.signal);
+ } catch (error) {
+ if (!request.clear(controller)) {
+ return;
+ }
+ setIsLoading(false);
+ if (isAbortError(error)) {
+ return;
+ }
+ console.warn("Failed to load text attachment:", error);
+ setFailureState(attachmentFailureFromError(error));
+ return;
+ }
+
+ if (!request.clear(controller)) {
+ return;
+ }
+ setIsLoading(false);
+ if (result.kind !== "loaded") {
+ if (result.kind === "expired") {
+ markExpired(fileId);
+ }
+ setFailureState(result);
+ return;
+ }
+ setContent(result.content);
+ void onPreview?.({ content: result.content, fileName });
+ }}
+ />
+ );
+
+ const framedButton = frameHref ? (
+
+ {button}
+
+ ) : (
+ button
+ );
+
+ return (
+
+ {framedButton}
+ {showStatus && isLoading ? (
+
+ Loading attachment preview…
+
+ ) : null}
+
+ );
+};
+
+const RemoteImageBlock: FC<{
+ fileId?: string;
+ href: string;
+ displayName: string;
+ onImageClick?: (src: string) => void;
+}> = ({ fileId, href, displayName, onImageClick }) => {
+ const { hasExpired, markExpired } = useExpiredFileIds();
+ const isKnownExpired = fileId !== undefined && hasExpired(fileId);
+ const [failureState, setFailureState] = useState(
+ () => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
+ );
+ const probeRequest = useLatestAbortController(isKnownExpired);
+
+ if (isKnownExpired) {
+ return (
+
+ );
+ }
+ if (failureState.kind !== "idle") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const FileCard: FC<{
+ block: FileAttachmentBlock;
+ href: string;
+}> = ({ block, href }) => {
+ const displayName = getAttachmentDisplayName(block);
+ const downloadName = getAttachmentDownloadName(block);
+ const badgeLabel = getAttachmentBadgeLabel(block);
+
+ return (
+ event.stopPropagation()}
+ aria-label={`Download ${displayName}`}
+ className="inline-flex h-16 max-w-sm items-center gap-3 rounded-md border border-solid border-border-default bg-surface-tertiary px-3 py-2 no-underline transition-colors hover:bg-surface-quaternary"
+ >
+
+ {badgeLabel ? (
+
+ {badgeLabel}
+
+ ) : (
+
+ )}
+
+
+
+ {displayName}
+
+
Download file
+
+
+
+ );
+};
+
+export const AttachmentBlock: FC<{
+ block: FileAttachmentBlock;
+ onImageClick?: (src: string) => void;
+ onTextFileClick?: (attachment: PreviewTextAttachment) => void;
+ framePreview?: boolean;
+ showTextStatus?: boolean;
+}> = ({
+ block,
+ onImageClick,
+ onTextFileClick,
+ framePreview = false,
+ showTextStatus = false,
+}) => {
+ const [revealedInlineText, setRevealedInlineText] = useState(false);
+ const href = getAttachmentHref(block);
+ const displayName = getAttachmentDisplayName(block);
+ const downloadName = getAttachmentDownloadName(block);
+
+ if (isTextPreviewAttachmentMediaType(block.media_type)) {
+ if (block.file_id) {
+ return (
+
+ );
+ }
+ if (block.data == null) {
+ return null;
+ }
+ const content = decodeInlineTextAttachment(block.data);
+ const button = (
+ {
+ setRevealedInlineText(true);
+ void onTextFileClick?.({ content, fileName: displayName });
+ }}
+ />
+ );
+ return framePreview && href ? (
+
+ {button}
+
+ ) : (
+ button
+ );
+ }
+
+ if (block.media_type.startsWith("image/")) {
+ if (!href) {
+ return null;
+ }
+ const image = (
+
+ );
+ return framePreview ? (
+
+ {image}
+
+ ) : (
+ image
+ );
+ }
+
+ if (!href) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
index a7dfdf35a3..d5c9c8bc46 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
@@ -11,6 +11,7 @@ import {
} from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { getChatFileURL } from "../../utils/chatAttachments";
+import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment";
import { ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
@@ -87,6 +88,10 @@ const ATTACHMENT_RESPONSES = new Map([
body: "Quarterly revenue increased 18% year over year after the new pricing rollout stabilized customer expansion.",
},
],
+ [
+ "storybook-json-text",
+ { status: 200, body: '{"status":"ok","items":[1,2,3]}' },
+ ],
[
"storybook-text-only",
{
@@ -140,6 +145,7 @@ const ATTACHMENT_RESPONSES = new Map([
contentType: "application/json",
},
],
+ ["storybook-text-error", { body: "Temporary failure", status: 503 }],
]);
let attachmentFetchCounts = new Map();
@@ -395,7 +401,7 @@ export const UserMessageWithExpiredImage: Story = {
const expiredTile = await findAttachmentTile(canvas, "Image expired");
expect(canvas.getByText("This upload has expired")).toBeInTheDocument();
expect(
- canvas.queryByRole("button", { name: "View image" }),
+ canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
// The tooltip explains the retention policy generically so the
@@ -433,10 +439,11 @@ export const UserMessageWithRepeatedExpiredImage: Story = {
);
expect(getAttachmentFetchCount("storybook-expired-image")).toBe(1);
expect(
- canvas.queryByRole("button", { name: "View image" }),
+ canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
},
};
+
/** File-id images that fail with a non-404 status render a generic failure tile. */
export const UserMessageWithFailedRemoteImage: Story = {
args: buildStoryArgs(
@@ -452,7 +459,7 @@ export const UserMessageWithFailedRemoteImage: Story = {
await findAttachmentTile(canvas, "Image failed to load");
expect(canvas.getByText("This image failed to load")).toBeInTheDocument();
expect(
- canvas.queryByRole("button", { name: "View image" }),
+ canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
// When the probe returns a structured error body, the tooltip
@@ -488,6 +495,7 @@ export const UserMessageWithUndisplayableRemoteImage: Story = {
);
},
};
+
/** Invalid inline image data skips the probe and renders the generic failure tile. */
export const UserMessageWithInvalidInlineImage: Story = {
args: buildStoryArgs(
@@ -505,7 +513,7 @@ export const UserMessageWithInvalidInlineImage: Story = {
canvas.getByText("Inline image data is corrupt"),
).toBeInTheDocument();
expect(
- canvas.queryByRole("button", { name: "View image" }),
+ canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
},
};
@@ -531,6 +539,70 @@ export const UserMessageWithTextAttachment: Story = {
},
};
+export const UserMessageWithJSONAttachment: Story = {
+ args: {
+ ...defaultArgs,
+ parsedMessages: parseMessagesWithMergedTools([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "user",
+ content: [
+ { type: "text", text: "Here is the structured report." },
+ {
+ type: "file",
+ file_id: "storybook-json-text",
+ media_type: "application/json",
+ name: "report.json",
+ },
+ ],
+ },
+ ]),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const textButton = await canvas.findByRole("button", {
+ name: "View report.json",
+ });
+ expect(textButton).toHaveTextContent("report.json");
+ await userEvent.click(textButton);
+ expect(await canvas.findByText(/"status":"ok"/i)).toBeInTheDocument();
+ },
+};
+
+export const UserMessageWithDownloadableFile: Story = {
+ args: {
+ ...defaultArgs,
+ parsedMessages: parseMessagesWithMergedTools([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "user",
+ content: [
+ { type: "text", text: "I attached the deployment report." },
+ {
+ type: "file",
+ media_type: "application/pdf",
+ file_id: "storybook-user-deployment-report",
+ name: "deployment-report.pdf",
+ },
+ ],
+ },
+ ]),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const downloadLink = canvas.getByRole("link", {
+ name: "Download deployment-report.pdf",
+ });
+ expect(downloadLink).toHaveAttribute(
+ "href",
+ "/api/experimental/chats/files/storybook-user-deployment-report",
+ );
+ expect(canvas.getByText("deployment-report.pdf")).toBeInTheDocument();
+ },
+};
+
export const UserMessageWithMultipleTextAttachments: Story = {
args: buildStoryArgs(
buildUserMessage({
@@ -630,6 +702,79 @@ export const UserMessageWithFailedTextAttachment: Story = {
},
};
+export const UserMessageWithInlineTextAttachment: Story = {
+ args: {
+ ...defaultArgs,
+ parsedMessages: parseMessagesWithMergedTools([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "user",
+ content: [
+ { type: "text", text: "Here is inline context:" },
+ {
+ type: "file",
+ media_type: "text/plain",
+ data: encodeInlineTextAttachment(
+ "Inline deployment note: verify the feature flag before rollout.",
+ ),
+ },
+ ],
+ },
+ ]),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const textButton = await canvas.findByRole("button", {
+ name: "View text attachment",
+ });
+ expect(textButton).toHaveTextContent(/Pasted text/i);
+ await userEvent.click(textButton);
+ expect(
+ await canvas.findByText(/Inline deployment note/i),
+ ).toBeInTheDocument();
+ },
+};
+
+/**
+ * Non-JSON error bodies (a bare `Temporary failure` text body with status 503)
+ * still surface the shared failure tile, and the raw body must not leak into
+ * the message stream where it would look like assistant content.
+ */
+export const UserMessageWithFailedTextAttachmentNonJSONBody: Story = {
+ args: {
+ ...defaultArgs,
+ parsedMessages: parseMessagesWithMergedTools([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "user",
+ content: [
+ { type: "text", text: "The preview fetch will fail." },
+ {
+ type: "file",
+ file_id: "storybook-text-error",
+ media_type: "text/plain",
+ name: "preview.txt",
+ },
+ ],
+ },
+ ]),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const textButton = await canvas.findByRole("button", {
+ name: "View preview.txt",
+ });
+ await userEvent.click(textButton);
+ await findAttachmentTile(canvas, "Attachment failed to load");
+ expect(
+ canvas.queryByRole("button", { name: "View preview.txt" }),
+ ).not.toBeInTheDocument();
+ expect(canvas.queryByText(/Temporary failure/i)).not.toBeInTheDocument();
+ },
+};
+
/** Visual regression: text and image attachments render at the same height. */
export const UserMessageWithMixedAttachments: Story = {
args: buildStoryArgs(
@@ -681,12 +826,6 @@ export const AssistantMessageWithImage: Story = {
{
...baseMessage,
id: 1,
- role: "user",
- content: [{ type: "text", text: "Generate an image" }],
- },
- {
- ...baseMessage,
- id: 2,
role: "assistant",
content: [
{ type: "text", text: "Here is the generated image:" },
@@ -694,6 +833,7 @@ export const AssistantMessageWithImage: Story = {
type: "file",
media_type: "image/png",
data: TEST_PNG_B64,
+ name: "generated-image.png",
},
],
},
@@ -701,8 +841,55 @@ export const AssistantMessageWithImage: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const images = canvas.getAllByRole("img", { name: "Attached image" });
+ const images = canvas.getAllByRole("img", { name: "generated-image.png" });
expect(images).toHaveLength(1);
+ expect(images[0]).toHaveAttribute(
+ "src",
+ `data:image/png;base64,${TEST_PNG_B64}`,
+ );
+ expect(
+ canvas.queryByRole("link", { name: "Download generated-image.png" }),
+ ).not.toBeInTheDocument();
+ const viewButton = canvas.getByRole("button", {
+ name: "View generated-image.png",
+ });
+ viewButton.focus();
+ expect(viewButton).toHaveFocus();
+ await waitFor(() => {
+ expect(
+ canvas.getByRole("link", { name: "Download generated-image.png" }),
+ ).toBeVisible();
+ });
+ },
+};
+
+export const AssistantMessageWithUnnamedDownloadableFile: Story = {
+ args: {
+ ...defaultArgs,
+ parsedMessages: buildMessages([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "assistant",
+ content: [
+ { type: "text", text: "I attached the file without a custom name." },
+ {
+ type: "file",
+ media_type: "application/pdf",
+ file_id: "storybook-unnamed-report",
+ },
+ ],
+ },
+ ]),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const downloadLink = canvas.getByRole("link", {
+ name: "Download Attached file",
+ });
+ expect(downloadLink).toBeInTheDocument();
+ expect(downloadLink).toHaveAttribute("download", "attachment.pdf");
+ expect(canvas.getByText("Attached file")).toBeInTheDocument();
},
};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
index 54b0ace204..b0d8f4adc7 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
@@ -29,6 +29,10 @@ import { WebSearchSources } from "../ChatElements/tools";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
+import {
+ AttachmentBlock,
+ type PreviewTextAttachment,
+} from "./AttachmentBlocks";
import { ExpiredFileIdsProvider } from "./ExpiredFileIdsContext";
import { deriveMessageDisplayState } from "./messageHelpers";
import { getEditableUserMessagePayload } from "./messageParsing";
@@ -39,7 +43,7 @@ import type {
ParsedMessageEntry,
RenderBlock,
} from "./types";
-import { FileBlock, UserMessageContent } from "./UserMessageContent";
+import { UserMessageContent } from "./UserMessageContent";
const getChatMessageTextContent = (
content: readonly TypesGen.ChatMessagePart[] | undefined,
@@ -135,7 +139,7 @@ export const BlockList: FC<{
subagentStatusOverrides?: Map;
mcpServers?: readonly TypesGen.MCPServerConfig[];
onImageClick?: (src: string) => void;
- onTextFileClick?: (content: string) => void;
+ onTextFileClick?: (attachment: PreviewTextAttachment) => void;
onImplementPlan?: () => Promise | void;
onSendAskUserQuestionResponse?: (message: string) => Promise | void;
isChatCompleted?: boolean;
@@ -283,11 +287,13 @@ export const BlockList: FC<{
}
case "file":
return (
-
);
case "sources":
@@ -388,7 +394,8 @@ const ChatMessageItem = memo<{
}) => {
const isUser = message.role === "user";
const [previewImage, setPreviewImage] = useState(null);
- const [previewText, setPreviewText] = useState(null);
+ const [previewText, setPreviewText] =
+ useState(null);
const displayState = deriveMessageDisplayState({
message,
parsed,
@@ -510,7 +517,8 @@ const ChatMessageItem = memo<{
)}
{previewText !== null && (
setPreviewText(null)}
/>
)}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx
index 6e3efc08fa..eea1f2dc56 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx
@@ -1,327 +1,16 @@
-import { AlertTriangleIcon, FileTextIcon } from "lucide-react";
-import { type FC, Fragment, useState } from "react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "#/components/Tooltip/Tooltip";
+import { type FC, Fragment } from "react";
import { cn } from "#/utils/cn";
-import { useLatestAbortController } from "../../hooks/useLatestAbortController";
-import {
- type AttachmentFailure,
- attachmentFailureFromError,
- getChatFileURL,
- isAbortError,
- probeAttachmentFailure,
-} from "../../utils/chatAttachments";
-import {
- decodeInlineTextAttachment,
- fetchTextAttachmentContent,
- formatTextAttachmentPreview,
-} from "../../utils/fetchTextAttachment";
-import { ImageThumbnail } from "../AgentChatInput";
import { Message, MessageContent } from "../ChatElements";
import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode";
-import { useExpiredFileIds } from "./ExpiredFileIdsContext";
+import {
+ AttachmentBlock,
+ type PreviewTextAttachment,
+} from "./AttachmentBlocks";
import type {
MessageDisplayState,
- UserFileRenderBlock,
UserInlineRenderBlock,
} from "./messageHelpers";
-type ChatImageSource =
- | { kind: "file"; fileId: string; src: string }
- | { kind: "inline"; src: string };
-
-type AttachmentFailureState = { kind: "idle" } | AttachmentFailure;
-
-type AttachmentFailureLabels = {
- expired: string;
- failed: string;
-};
-
-const attachmentRetentionTooltip =
- "Chat attachments are deleted after the retention window set for this deployment.";
-
-const imageAttachmentFailureLabels: AttachmentFailureLabels = {
- expired: "Image expired",
- failed: "Image failed to load",
-};
-
-const textAttachmentFailureLabels: AttachmentFailureLabels = {
- expired: "Attachment expired",
- failed: "Attachment failed to load",
-};
-
-const InlineTextAttachmentButton: FC<{
- content: string;
- onPreview?: (content: string) => void;
- isPlaceholder?: boolean;
-}> = ({ content, onPreview, isPlaceholder }) => {
- return (
-
- );
-};
-
-const TextAttachmentButton: FC<{
- fileId: string;
- onPreview?: (content: string) => void;
-}> = ({ fileId, onPreview }) => {
- const { hasExpired, markExpired } = useExpiredFileIds();
- const isKnownExpired = hasExpired(fileId);
- const [content, setContent] = useState(null);
- const [failureState, setFailureState] = useState(
- () => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
- );
- const request = useLatestAbortController(isKnownExpired);
-
- if (failureState.kind === "expired" || isKnownExpired) {
- return (
-
- );
- }
- if (failureState.kind === "failed") {
- return (
-
- );
- }
-
- return (
- {
- if (content !== null) {
- onPreview?.(content);
- return;
- }
-
- const controller = request.start();
-
- void fetchTextAttachmentContent(fileId, controller.signal)
- .then((result) => {
- if (!request.clear(controller)) {
- return;
- }
- if (result.kind === "loaded") {
- setContent(result.content);
- onPreview?.(result.content);
- return;
- }
- if (result.kind === "expired") {
- markExpired(fileId);
- }
- setFailureState(result);
- })
- .catch((error) => {
- if (!request.clear(controller)) {
- return;
- }
- if (isAbortError(error)) {
- return;
- }
- console.warn("Failed to load text attachment:", error);
- setFailureState(attachmentFailureFromError(error));
- });
- }}
- />
- );
-};
-
-const AttachmentFallbackTile: FC<{
- state: AttachmentFailure;
- labels: AttachmentFailureLabels;
- className?: string;
-}> = ({ state, labels, className = "h-16 w-16" }) => {
- const label = state.kind === "expired" ? labels.expired : labels.failed;
-
- const tile = (
-
- );
-
- // Only surface a tooltip when we have something to add:
- // - "expired" explains the retention policy.
- // - "failed" with a detail surfaces the API error or network reason.
- // A bare "failed" (e.g. an inline base64 decode failure, where the
- // browser exposes nothing useful) stays a plain tile.
- const tooltipBody =
- state.kind === "expired" ? attachmentRetentionTooltip : state.detail;
- if (!tooltipBody) {
- return tile;
- }
-
- return (
-
- {tile}
-
- {tooltipBody}
-
-
- );
-};
-
-const ChatImageBlock: FC<{
- source: ChatImageSource;
- onImageClick?: (src: string) => void;
-}> = ({ source, onImageClick }) => {
- const { hasExpired, markExpired } = useExpiredFileIds();
- const isKnownExpired = source.kind === "file" && hasExpired(source.fileId);
- const [failureState, setFailureState] = useState(
- () => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
- );
- const probeRequest = useLatestAbortController(isKnownExpired);
-
- if (failureState.kind === "expired" || isKnownExpired) {
- return (
-
- );
- }
- if (failureState.kind === "failed") {
- return (
-
- );
- }
-
- return (
-
- );
-};
-
-export const FileBlock: FC<{
- block: UserFileRenderBlock;
- onImageClick?: (src: string) => void;
- onTextFileClick?: (content: string) => void;
-}> = ({ block, onImageClick, onTextFileClick }) => {
- if (block.media_type === "text/plain") {
- if (block.file_id) {
- return (
-
- );
- }
- if (block.data != null) {
- return (
-
- );
- }
- }
- if (!block.media_type.startsWith("image/")) {
- return null;
- }
- const source: ChatImageSource = block.file_id
- ? {
- kind: "file",
- fileId: block.file_id,
- src: getChatFileURL(block.file_id),
- }
- : {
- kind: "inline",
- src: `data:${block.media_type};base64,${block.data ?? ""}`,
- };
- return ;
-};
-
const renderUserInlineBlock = (block: UserInlineRenderBlock, index: number) => {
if (block.type === "response") {
return {block.text};
@@ -344,7 +33,7 @@ export const UserMessageContent: FC<{
isEditing?: boolean;
fadeFromBottom?: boolean;
onImageClick?: (src: string) => void;
- onTextFileClick?: (content: string) => void;
+ onTextFileClick?: (attachment: PreviewTextAttachment) => void;
}> = ({
displayState,
markdown,
@@ -388,11 +77,12 @@ export const UserMessageContent: FC<{
)}
>
{displayState.userFileBlocks.map((block, index) => (
-
))}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts
index b416a902e0..74f8003574 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts
@@ -5,7 +5,7 @@ export type UserInlineRenderBlock =
| Extract
| Extract;
-export type UserFileRenderBlock = Extract;
+type UserFileRenderBlock = Extract;
export type MessageDisplayState = {
shouldHide: boolean;
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts
index 5c3581c9c2..41d2f6988e 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts
@@ -3,6 +3,7 @@ import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated";
import { getSubagentDescriptor } from "../ChatElements/tools/subagentDescriptor";
import {
buildSubagentMaps,
+ getEditableUserMessagePayload,
mergeTools,
parseMessageContent,
parseMessagesWithMergedTools,
@@ -73,6 +74,86 @@ describe("parseToolResultIsError", () => {
});
});
+describe("getEditableUserMessagePayload", () => {
+ it("keeps only editable stored attachments", () => {
+ const cases = [
+ {
+ message: {
+ id: 1,
+ chat_id: "chat-1",
+ created_at: "2026-04-21T00:00:00.000Z",
+ role: "user",
+ content: [
+ { type: "text", text: "Please edit this draft." },
+ { type: "file", media_type: "image/png", file_id: "image-file" },
+ {
+ type: "file",
+ media_type: "application/json",
+ file_id: "json-file",
+ name: "report.json",
+ },
+ {
+ type: "file",
+ media_type: "application/pdf",
+ file_id: "pdf-file",
+ name: "manual.pdf",
+ },
+ {
+ type: "file",
+ media_type: "application/zip",
+ file_id: "zip-file",
+ name: "archive.zip",
+ },
+ ],
+ } satisfies ChatMessage,
+ want: {
+ text: "Please edit this draft.",
+ fileBlocks: [
+ { type: "file", media_type: "image/png", file_id: "image-file" },
+ {
+ type: "file",
+ media_type: "application/json",
+ file_id: "json-file",
+ name: "report.json",
+ },
+ {
+ type: "file",
+ media_type: "application/pdf",
+ file_id: "pdf-file",
+ name: "manual.pdf",
+ },
+ ],
+ },
+ },
+ {
+ message: {
+ id: 2,
+ chat_id: "chat-1",
+ created_at: "2026-04-21T00:00:00.000Z",
+ role: "user",
+ content: [
+ { type: "text", text: "Share the archive instead." },
+ {
+ type: "file",
+ media_type: "application/zip",
+ file_id: "zip-file",
+ name: "archive.zip",
+ },
+ ],
+ } satisfies ChatMessage,
+ want: {
+ text: "Share the archive instead.",
+ fileBlocks: undefined,
+ },
+ },
+ ];
+
+ for (const { message, want } of cases) {
+ expect(getEditableUserMessagePayload(message)).toEqual(want);
+ }
+ });
+});
+
describe("parseMessageContent", () => {
it("returns empty result for undefined content", () => {
const result = parseMessageContent(undefined);
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts
index c748dc9bce..c665710cf4 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts
@@ -237,11 +237,18 @@ export const parseMessageContent = (
return parsed;
};
+const isEditableAttachmentMediaType = (mediaType: string): boolean =>
+ mediaType.startsWith("image/") ||
+ mediaType === "text/plain" ||
+ mediaType === "text/markdown" ||
+ mediaType === "text/csv" ||
+ mediaType === "application/json" ||
+ mediaType === "application/pdf";
+
const isEditableUserMessageFileBlock = (
block: RenderBlock,
): block is TypesGen.ChatFilePart =>
- block.type === "file" &&
- (block.media_type.startsWith("image/") || block.media_type === "text/plain");
+ block.type === "file" && isEditableAttachmentMediaType(block.media_type);
export const getEditableUserMessagePayload = (
message: TypesGen.ChatMessage,
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
index 63df4c856d..5544aa2fd6 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
@@ -1343,7 +1343,7 @@ export const ComputerTextFallback: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Text-only results are collapsed by default (no image).
- const toggle = canvas.getByRole("button", { name: /Screenshot/ });
+ const toggle = canvas.getByRole("button", { name: "Screenshot" });
expect(toggle).toBeInTheDocument();
expect(canvas.queryByRole("img")).toBeNull();
@@ -1398,6 +1398,50 @@ export const ComputerArrayResult: Story = {
},
};
+export const ComputerPromotedAttachmentArrayResult: Story = {
+ args: {
+ name: "computer",
+ status: "completed",
+ result: [
+ {
+ type: "image",
+ data: DESKTOP_SCREENSHOT_BASE64,
+ mime_type: "image/jpeg",
+ attachment_file_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+ attachment_name: "screenshot-2026-04-21T00-00-00Z.png",
+ },
+ ],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const toggle = canvas.getByRole("button", { name: "Screenshot" });
+ expect(toggle).toBeInTheDocument();
+ expect(
+ canvas.queryByRole("img", { name: "Screenshot from computer tool" }),
+ ).toBeNull();
+
+ await userEvent.click(toggle);
+ expect(
+ canvas.getByText("Attached screenshot-2026-04-21T00-00-00Z.png"),
+ ).toBeInTheDocument();
+ },
+};
+
+export const AttachFileLabelFallsBackToPathBasename: Story = {
+ args: {
+ name: "attach_file",
+ status: "completed",
+ args: {
+ path: "docs/runbooks/incident.md",
+ },
+ result: {},
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ expect(canvas.getByText("Attached incident.md")).toBeInTheDocument();
+ },
+};
+
// ---------------------------------------------------------------------------
// Tool failure display stories
// ---------------------------------------------------------------------------
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
index 278f88ef60..fd84d2a590 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
@@ -707,26 +707,30 @@ const ComputerRenderer: FC = ({
result,
isError,
}) => {
- // The result can be a single object with {data, text, mime_type}
- // or an array of content blocks.
let imageData = "";
let mimeType = "image/png";
let text = "";
+ let attachmentFileId = "";
+ let attachmentName = "";
if (Array.isArray(result)) {
for (const block of result) {
const blockRec = asRecord(block);
- if (blockRec) {
- if (blockRec.type === "image" || asString(blockRec.data)) {
- imageData = asString(blockRec.data);
- mimeType = asString(blockRec.mime_type) || "image/png";
- }
- if (
- blockRec.type === "text" ||
- (!imageData && asString(blockRec.text))
- ) {
- text = asString(blockRec.text);
- }
+ if (!blockRec) {
+ continue;
+ }
+ if (blockRec.type === "image" || asString(blockRec.data)) {
+ imageData = asString(blockRec.data);
+ mimeType = asString(blockRec.mime_type) || "image/png";
+ }
+ if (blockRec.type === "text" || (!imageData && asString(blockRec.text))) {
+ text = asString(blockRec.text);
+ }
+ if (!attachmentFileId) {
+ attachmentFileId = asString(blockRec.attachment_file_id);
+ }
+ if (!attachmentName) {
+ attachmentName = asString(blockRec.attachment_name);
}
}
} else {
@@ -735,6 +739,17 @@ const ComputerRenderer: FC = ({
imageData = asString(rec.data);
mimeType = asString(rec.mime_type) || "image/png";
text = asString(rec.text);
+ attachmentFileId = asString(rec.attachment_file_id);
+ attachmentName = asString(rec.attachment_name);
+ }
+ }
+
+ if (attachmentFileId) {
+ imageData = "";
+ if (!text) {
+ text = attachmentName
+ ? `Attached ${attachmentName}`
+ : "Attached screenshot";
}
}
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx
index 9194ab064b..cd35d94d7a 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx
@@ -219,6 +219,18 @@ export const ToolLabel: React.FC<{
Summarized
);
+ case "attach_file": {
+ const attachedName =
+ (parsedResult ? asString(parsedResult.name) : "") ||
+ (parsed ? asString(parsed.name) : "") ||
+ (parsed ? asString(parsed.path).split("/").pop() : "") ||
+ "file";
+ return (
+
+ {`Attached ${attachedName}`}
+
+ );
+ }
case "computer":
return (
diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts
index d7fe0ef226..db1a94c8e5 100644
--- a/site/src/pages/AgentsPage/utils/chatAttachments.ts
+++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts
@@ -7,7 +7,7 @@ export type AttachmentFailure =
| { kind: "failed"; detail?: string };
export const getChatFileURL = (fileId: string) =>
- `/api/experimental/chats/files/${fileId}`;
+ `/api/experimental/chats/files/${encodeURIComponent(fileId)}`;
export const isAbortError = (error: unknown): error is Error =>
error instanceof Error && error.name === "AbortError";
diff --git a/site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts b/site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts
index f437e84307..c90cda29e2 100644
--- a/site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts
+++ b/site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts
@@ -1,13 +1,11 @@
import {
decodeInlineTextAttachment,
+ encodeInlineTextAttachment,
+ fetchTextAttachmentContent,
formatTextAttachmentPreview,
+ getTextAttachmentErrorMessage,
} from "./fetchTextAttachment";
-const encodeUtf8Base64 = (value: string) => {
- const bytes = new TextEncoder().encode(value);
- return btoa(String.fromCharCode(...bytes));
-};
-
describe("formatTextAttachmentPreview", () => {
it('returns "Pasted text" for empty content', () => {
expect(formatTextAttachmentPreview("")).toBe("Pasted text");
@@ -41,7 +39,9 @@ describe("decodeInlineTextAttachment", () => {
it("decodes base64-encoded UTF-8 text", () => {
const text = "Hello 👋 café";
- expect(decodeInlineTextAttachment(encodeUtf8Base64(text))).toBe(text);
+ expect(decodeInlineTextAttachment(encodeInlineTextAttachment(text))).toBe(
+ text,
+ );
});
it("falls back to the raw string when base64 decoding fails", () => {
@@ -52,3 +52,70 @@ describe("decodeInlineTextAttachment", () => {
expect(warn).toHaveBeenCalled();
});
});
+
+describe("fetchTextAttachmentContent", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns a loaded result when the fetch succeeds", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("hello from the server", { status: 200 }),
+ );
+
+ const fileId = "folder/file-1?preview=yes";
+ await expect(fetchTextAttachmentContent(fileId)).resolves.toEqual({
+ kind: "loaded",
+ content: "hello from the server",
+ });
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ "/api/experimental/chats/files/folder%2Ffile-1%3Fpreview%3Dyes",
+ expect.anything(),
+ );
+ });
+
+ it("returns the API message for unauthorized attachment fetches", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({ message: "Sign in again to view files." }),
+ {
+ status: 401,
+ statusText: "Unauthorized",
+ headers: { "Content-Type": "application/json" },
+ },
+ ),
+ );
+
+ await expect(fetchTextAttachmentContent("file-2")).resolves.toEqual({
+ kind: "failed",
+ detail: "Sign in again to view files.",
+ });
+ });
+
+ it("returns a classified failure when the fetch responds non-OK", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("nope", { status: 503 }),
+ );
+
+ const result = await fetchTextAttachmentContent("file-3");
+ expect(result.kind).not.toBe("loaded");
+ });
+});
+
+describe("getTextAttachmentErrorMessage", () => {
+ it("suppresses DOMException abort errors", () => {
+ expect(
+ getTextAttachmentErrorMessage(new DOMException("aborted", "AbortError")),
+ ).toBeNull();
+ });
+
+ it("suppresses structural abort errors", () => {
+ expect(getTextAttachmentErrorMessage({ name: "AbortError" })).toBeNull();
+ });
+
+ it("falls back to the retry message for other failures", () => {
+ expect(getTextAttachmentErrorMessage(new Error("boom"))).toBe(
+ "Couldn't load preview. Select again to retry.",
+ );
+ });
+});
diff --git a/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts b/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts
index e1b7fbb529..8e072653c9 100644
--- a/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts
+++ b/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts
@@ -39,6 +39,27 @@ export function decodeInlineTextAttachment(content: string): string {
}
}
+/**
+ * Encodes UTF-8 text as base64. Inverse of decodeInlineTextAttachment.
+ */
+export function encodeInlineTextAttachment(text: string): string {
+ const bytes = new TextEncoder().encode(text);
+ return btoa(String.fromCharCode(...bytes));
+}
+
+export function getTextAttachmentErrorMessage(error: unknown): string | null {
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "name" in error &&
+ error.name === "AbortError"
+ ) {
+ return null;
+ }
+
+ return "Couldn't load preview. Select again to retry.";
+}
+
/**
* Fetches the text content of a chat file attachment by its ID.
*/