fix: handle expired chat file attachments in replay and UI (#24518)

Closes CODAGT-216

## Problem

`dbpurge` deletes `chat_files` rows after the deployment's configured
retention window, but `chat_messages.content` can still contain
`file_id` references to those files. On replay, that left the Anthropic
provider with an empty file payload and a `400 image cannot be empty`
error. In the UI, the same missing file showed up as a broken image.

## Fix

- Backend: when replay hits a `file_id` whose bytes are gone, replace it
with a short text placeholder instead of emitting an empty file part. We
could also drop the missing attachment entirely, but that would silently
remove context from the replay and make the conversation harder for the
model to interpret. The placeholder keeps the request valid while still
telling the model that a file used to be there and is no longer
available.
- Frontend: classify chat image failures instead of treating every
broken image the same.
- `404` file fetches render `Image expired`, with a tooltip explaining
that chat attachments are deleted after the retention window set for the
deployment.
- Other remote failures render `Image failed to load`, with a tooltip
that surfaces server/network detail when available.
- Invalid inline image data still renders `Image failed to load` without
a probe.
This commit is contained in:
Ethan
2026-04-22 14:10:51 +10:00
committed by GitHub
parent f77827e84a
commit 353e522614
13 changed files with 1115 additions and 264 deletions
@@ -1,5 +1,5 @@
import { AlertTriangleIcon, ClipboardPasteIcon, XIcon } from "lucide-react";
import { type FC, useEffect, useRef } from "react";
import type { FC, ReactEventHandler } from "react";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
@@ -7,6 +7,8 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { useLatestAbortController } from "../hooks/useLatestAbortController";
import { isAbortError } from "../utils/chatAttachments";
import {
fetchTextAttachmentContent,
formatTextAttachmentPreview,
@@ -23,7 +25,8 @@ export const ImageThumbnail: FC<{
previewUrl: string;
name: string;
className?: string;
}> = ({ previewUrl, name, className }) => (
onError?: ReactEventHandler<HTMLImageElement>;
}> = ({ previewUrl, name, className, onError }) => (
<img
src={previewUrl}
alt={name}
@@ -31,6 +34,7 @@ export const ImageThumbnail: FC<{
"h-16 w-16 rounded-md border border-border-default object-cover",
className,
)}
onError={onError}
/>
);
@@ -54,11 +58,7 @@ export const AttachmentPreview: FC<{
onTextPreview,
onInlineText,
}) => {
const textAttachmentLoadControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => textAttachmentLoadControllerRef.current?.abort();
}, []);
const textAttachmentRequest = useLatestAbortController();
if (attachments.length === 0) return null;
@@ -66,30 +66,32 @@ export const AttachmentPreview: FC<{
content: string | undefined,
fileId: string | undefined,
): Promise<string | undefined> => {
textAttachmentLoadControllerRef.current?.abort();
textAttachmentRequest.abort();
if (content !== undefined || !fileId) {
textAttachmentLoadControllerRef.current = null;
return content;
}
const controller = new AbortController();
textAttachmentLoadControllerRef.current = controller;
const controller = textAttachmentRequest.start();
try {
const fetchedContent = await fetchTextAttachmentContent(
const result = await fetchTextAttachmentContent(
fileId,
controller.signal,
);
if (textAttachmentLoadControllerRef.current === controller) {
textAttachmentLoadControllerRef.current = null;
}
return fetchedContent;
} catch (err) {
if (textAttachmentLoadControllerRef.current === controller) {
textAttachmentLoadControllerRef.current = null;
}
if (err instanceof Error && err.name === "AbortError") {
if (!textAttachmentRequest.clear(controller)) {
return undefined;
}
console.error("Failed to load text attachment:", err);
if (result.kind === "loaded") {
return result.content;
}
console.warn("Failed to load text attachment:", result);
return undefined;
} catch (err) {
if (!textAttachmentRequest.clear(controller)) {
return undefined;
}
if (isAbortError(err)) {
return undefined;
}
console.warn("Failed to load text attachment:", err);
return undefined;
}
};
@@ -1,6 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, spyOn, userEvent, within } from "storybook/test";
import {
expect,
fireEvent,
fn,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { getChatFileURL } from "../../utils/chatAttachments";
import { ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
@@ -58,30 +68,93 @@ const askUserQuestionSubmittedResponse = [
"2. Release Plan: Small beta",
].join("\n");
const TEXT_ATTACHMENT_RESPONSES = new Map<string, string>([
type AttachmentResponse = {
status: number;
body: string;
contentType?: string;
};
const FAILED_ATTACHMENT_API_MESSAGE = "Failed to get chat file.";
const UNDISPLAYABLE_REMOTE_ATTACHMENT_MESSAGE =
"File exists but could not be displayed.";
const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
[
"storybook-test-text",
"Quarterly revenue increased 18% year over year after the new pricing rollout stabilized customer expansion.",
{
status: 200,
body: "Quarterly revenue increased 18% year over year after the new pricing rollout stabilized customer expansion.",
},
],
[
"storybook-text-only",
"Runbook note: restart the worker after updating the queue configuration to pick up the new concurrency limits.",
{
status: 200,
body: "Runbook note: restart the worker after updating the queue configuration to pick up the new concurrency limits.",
},
],
[
"storybook-text-1",
"First context file: deployment checklist and rollback instructions for the release candidate.",
{
status: 200,
body: "First context file: deployment checklist and rollback instructions for the release candidate.",
},
],
[
"storybook-text-2",
"Second context file: service logs showing a transient timeout while the cache warmed up.",
{
status: 200,
body: "Second context file: service logs showing a transient timeout while the cache warmed up.",
},
],
[
"storybook-text-3",
"Third context file: local development configuration overrides for reproducing the issue.",
{
status: 200,
body: "Third context file: local development configuration overrides for reproducing the issue.",
},
],
["storybook-expired-image", { status: 404, body: "" }],
["storybook-undisplayable-image", { status: 200, body: "" }],
[
"storybook-failed-image",
{
status: 500,
body: JSON.stringify({
message: FAILED_ATTACHMENT_API_MESSAGE,
detail: "db: connection reset",
}),
contentType: "application/json",
},
],
["storybook-expired-text", { status: 404, body: "" }],
[
"storybook-failed-text",
{
status: 500,
body: JSON.stringify({
message: FAILED_ATTACHMENT_API_MESSAGE,
detail: "db: connection reset",
}),
contentType: "application/json",
},
],
]);
const mockTextAttachmentFetch = () => {
let attachmentFetchCounts = new Map<string, number>();
const recordAttachmentFetch = (fileId: string) => {
attachmentFetchCounts.set(
fileId,
(attachmentFetchCounts.get(fileId) ?? 0) + 1,
);
};
const getAttachmentFetchCount = (fileId: string) =>
attachmentFetchCounts.get(fileId) ?? 0;
const mockAttachmentFetch = () => {
const originalFetch = globalThis.fetch;
spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url =
@@ -91,9 +164,15 @@ const mockTextAttachmentFetch = () => {
? input.toString()
: input.url;
for (const [fileId, content] of TEXT_ATTACHMENT_RESPONSES) {
for (const [fileId, response] of ATTACHMENT_RESPONSES) {
if (url.endsWith(fileId)) {
return new Response(content, { status: 200 });
recordAttachmentFetch(fileId);
return new Response(response.body, {
status: response.status,
headers: response.contentType
? { "Content-Type": response.contentType }
: undefined,
});
}
}
@@ -101,6 +180,86 @@ const mockTextAttachmentFetch = () => {
});
};
const buildTextPart = (text: string): TypesGen.ChatTextPart => ({
type: "text",
text,
});
const buildFilePart = (
part: Omit<TypesGen.ChatFilePart, "type">,
): TypesGen.ChatFilePart => ({
type: "file",
...part,
});
const buildTextAttachmentPart = (fileId: string): TypesGen.ChatFilePart =>
buildFilePart({ file_id: fileId, media_type: "text/plain" });
const buildImageAttachmentPart = (
fileId: string,
mediaType = "image/png",
): TypesGen.ChatFilePart =>
buildFilePart({ file_id: fileId, media_type: mediaType });
const buildInlineAttachmentPart = (
mediaType: string,
data: string,
): TypesGen.ChatFilePart => buildFilePart({ media_type: mediaType, data });
const buildUserMessage = ({
id = 1,
text,
files = [],
createdAt = baseMessage.created_at,
}: {
id?: number;
text?: string;
files?: TypesGen.ChatFilePart[];
createdAt?: string;
}): TypesGen.ChatMessage => ({
...baseMessage,
created_at: createdAt,
id,
role: "user",
content: [...(text ? [buildTextPart(text)] : []), ...files],
});
const buildStoryArgs = (...messages: TypesGen.ChatMessage[]) => ({
...defaultArgs,
parsedMessages: buildMessages(messages),
});
const findAttachmentTile = async (
canvas: ReturnType<typeof within>,
label: string,
) => {
const tile = await canvas.findByRole("img", { name: label });
expect(canvas.getByText(label)).toBeInTheDocument();
return tile;
};
const hoverAndExpectTooltip = async (
element: HTMLElement,
text: RegExp | string,
) => {
await userEvent.hover(element);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent(text);
return tooltip;
};
const waitForTooltipWrappedAttachmentTile = async (
canvas: ReturnType<typeof within>,
label: string,
) => {
await waitFor(() =>
expect(canvas.getByRole("img", { name: label })).toHaveAttribute(
"data-state",
),
);
return canvas.getByRole("img", { name: label });
};
const defaultArgs: Omit<
React.ComponentProps<typeof ConversationTimeline>,
"parsedMessages"
@@ -119,7 +278,8 @@ const meta: Meta<typeof ConversationTimeline> = {
),
],
beforeEach: () => {
mockTextAttachmentFetch();
attachmentFetchCounts = new Map();
mockAttachmentFetch();
},
};
export default meta;
@@ -202,24 +362,12 @@ export const UserMessageWithMultipleImages: Story = {
/** File-id images use a server URL instead of inline base64 data. */
export const UserMessageWithFileIdImage: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Uploaded via file ID" },
{
type: "file",
media_type: "image/png",
file_id: "storybook-test-image",
},
],
},
]),
},
args: buildStoryArgs(
buildUserMessage({
text: "Uploaded via file ID",
files: [buildImageAttachmentPart("storybook-test-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
@@ -227,30 +375,148 @@ export const UserMessageWithFileIdImage: Story = {
// Verify file_id path is used, not a base64 data URI.
expect(images[0]).toHaveAttribute(
"src",
"/api/experimental/chats/files/storybook-test-image",
getChatFileURL("storybook-test-image"),
);
},
};
export const UserMessageWithTextAttachment: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Here is some context from our docs:" },
{
type: "file",
file_id: "storybook-test-text",
media_type: "text/plain",
},
],
},
]),
/** File-id images that probe as 404 render an expired placeholder. */
export const UserMessageWithExpiredImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This upload has expired",
files: [buildImageAttachmentPart("storybook-expired-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
const expiredTile = await findAttachmentTile(canvas, "Image expired");
expect(canvas.getByText("This upload has expired")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View image" }),
).not.toBeInTheDocument();
// The tooltip explains the retention policy generically so the
// copy survives any operator-chosen retention window.
await hoverAndExpectTooltip(
expiredTile,
/deleted after the retention window/i,
);
},
};
/** Duplicate expired file IDs reuse the first probe result page-wide. */
export const UserMessageWithRepeatedExpiredImage: Story = {
args: buildStoryArgs(
buildUserMessage({
id: 1,
text: "First reference to the expired upload",
files: [buildImageAttachmentPart("storybook-expired-image")],
}),
buildUserMessage({
id: 2,
text: "Second reference to the same expired upload",
files: [buildImageAttachmentPart("storybook-expired-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
expect(images).toHaveLength(2);
fireEvent.error(images[0]);
await waitFor(() =>
expect(
canvas.getAllByRole("img", { name: "Image expired" }),
).toHaveLength(2),
);
expect(getAttachmentFetchCount("storybook-expired-image")).toBe(1);
expect(
canvas.queryByRole("button", { name: "View image" }),
).not.toBeInTheDocument();
},
};
/** File-id images that fail with a non-404 status render a generic failure tile. */
export const UserMessageWithFailedRemoteImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This image failed to load",
files: [buildImageAttachmentPart("storybook-failed-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
await findAttachmentTile(canvas, "Image failed to load");
expect(canvas.getByText("This image failed to load")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View image" }),
).not.toBeInTheDocument();
// When the probe returns a structured error body, the tooltip
// surfaces the API's message so the viewer has something
// actionable instead of a bare "failed to load". The label
// doesn't change when the probe settles (still "Image failed
// to load"), and the tile's DOM node is replaced when the
// Tooltip wrapper mounts, so re-query each time and wait for
// the Radix-stamped data-state attribute before hovering.
await hoverAndExpectTooltip(
await waitForTooltipWrappedAttachmentTile(canvas, "Image failed to load"),
FAILED_ATTACHMENT_API_MESSAGE,
);
},
};
/** A successful follow-up probe still maps to the generic failure tile. */
export const UserMessageWithUndisplayableRemoteImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This image exists but cannot be displayed",
files: [buildImageAttachmentPart("storybook-undisplayable-image")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
await findAttachmentTile(canvas, "Image failed to load");
await hoverAndExpectTooltip(
await waitForTooltipWrappedAttachmentTile(canvas, "Image failed to load"),
UNDISPLAYABLE_REMOTE_ATTACHMENT_MESSAGE,
);
},
};
/** Invalid inline image data skips the probe and renders the generic failure tile. */
export const UserMessageWithInvalidInlineImage: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "Inline image data is corrupt",
files: [buildInlineAttachmentPart("image/png", "not-valid-base64")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole("img", { name: "Attached image" });
fireEvent.error(image);
await findAttachmentTile(canvas, "Image failed to load");
expect(
canvas.getByText("Inline image data is corrupt"),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View image" }),
).not.toBeInTheDocument();
},
};
export const UserMessageWithTextAttachment: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "Here is some context from our docs:",
files: [buildTextAttachmentPart("storybook-test-text")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
@@ -266,35 +532,17 @@ export const UserMessageWithTextAttachment: Story = {
};
export const UserMessageWithMultipleTextAttachments: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
created_at: "2025-01-15T10:00:00Z",
role: "user",
content: [
{ type: "text", text: "Here are several context files:" },
{
type: "file",
file_id: "storybook-text-1",
media_type: "text/plain",
},
{
type: "file",
file_id: "storybook-text-2",
media_type: "text/plain",
},
{
type: "file",
file_id: "storybook-text-3",
media_type: "text/plain",
},
],
},
]),
},
args: buildStoryArgs(
buildUserMessage({
createdAt: "2025-01-15T10:00:00Z",
text: "Here are several context files:",
files: [
buildTextAttachmentPart("storybook-text-1"),
buildTextAttachmentPart("storybook-text-2"),
buildTextAttachmentPart("storybook-text-3"),
],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButtons = await canvas.findAllByRole("button", {
@@ -305,23 +553,11 @@ export const UserMessageWithMultipleTextAttachments: Story = {
};
export const UserMessageWithTextAttachmentOnly: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{
type: "file",
file_id: "storybook-text-only",
media_type: "text/plain",
},
],
},
]),
},
args: buildStoryArgs(
buildUserMessage({
files: [buildTextAttachmentPart("storybook-text-only")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
@@ -335,31 +571,76 @@ export const UserMessageWithTextAttachmentOnly: Story = {
},
};
export const UserMessageWithExpiredTextAttachment: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This pasted context has expired",
files: [buildTextAttachmentPart("storybook-expired-text")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
await userEvent.click(textButton);
const expiredTile = await findAttachmentTile(canvas, "Attachment expired");
expect(
canvas.getByText("This pasted context has expired"),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View text attachment" }),
).not.toBeInTheDocument();
await hoverAndExpectTooltip(
expiredTile,
/deleted after the retention window/i,
);
},
};
export const UserMessageWithFailedTextAttachment: Story = {
args: buildStoryArgs(
buildUserMessage({
text: "This pasted context failed to load",
files: [buildTextAttachmentPart("storybook-failed-text")],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
await userEvent.click(textButton);
await findAttachmentTile(canvas, "Attachment failed to load");
expect(
canvas.getByText("This pasted context failed to load"),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View text attachment" }),
).not.toBeInTheDocument();
await hoverAndExpectTooltip(
await waitForTooltipWrappedAttachmentTile(
canvas,
"Attachment failed to load",
),
FAILED_ATTACHMENT_API_MESSAGE,
);
},
};
/** Visual regression: text and image attachments render at the same height. */
export const UserMessageWithMixedAttachments: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Here is a screenshot and some context" },
{
type: "file",
media_type: "image/png",
data: TEST_PNG_B64,
},
{
type: "file",
file_id: "storybook-test-text",
media_type: "text/plain",
},
],
},
]),
},
args: buildStoryArgs(
buildUserMessage({
text: "Here is a screenshot and some context",
files: [
buildInlineAttachmentPart("image/png", TEST_PNG_B64),
buildTextAttachmentPart("storybook-test-text"),
],
}),
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
@@ -29,6 +29,7 @@ import { WebSearchSources } from "../ChatElements/tools";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import { ExpiredFileIdsProvider } from "./ExpiredFileIdsContext";
import { deriveMessageDisplayState } from "./messageHelpers";
import { getEditableUserMessagePayload } from "./messageParsing";
import { useSmoothStreamingText } from "./SmoothText";
@@ -903,48 +904,55 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
: undefined;
return (
<div data-testid="conversation-timeline" className="flex flex-col gap-2">
{parsedMessages.map(({ message, parsed }, msgIdx) => {
if (message.role === "user") {
<ExpiredFileIdsProvider>
<div
data-testid="conversation-timeline"
className="flex flex-col gap-2"
>
{parsedMessages.map(({ message, parsed }, msgIdx) => {
if (message.role === "user") {
return (
<StickyUserMessage
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
/>
);
}
// Hide actions on assistant messages that are not
// the last in a consecutive assistant chain.
const next = parsedMessages[msgIdx + 1];
const isLastInChain = !next || next.message.role === "user";
return (
<StickyUserMessage
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
latestAskUserQuestionToolId={latestAskUserQuestionToolId}
askUserQuestionResponseTextByToolId={
historicalAskUserQuestionResponseTextByToolId
}
hasUserResponseAfterAskQuestion={
hasUserResponseAfterAskQuestion
}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
hideActions={!isLastInChain}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
/>
);
}
// Hide actions on assistant messages that are not
// the last in a consecutive assistant chain.
const next = parsedMessages[msgIdx + 1];
const isLastInChain = !next || next.message.role === "user";
return (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
latestAskUserQuestionToolId={latestAskUserQuestionToolId}
askUserQuestionResponseTextByToolId={
historicalAskUserQuestionResponseTextByToolId
}
hasUserResponseAfterAskQuestion={hasUserResponseAfterAskQuestion}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
hideActions={!isLastInChain}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
/>
);
})}
</div>
})}
</div>
</ExpiredFileIdsProvider>
);
},
);
@@ -0,0 +1,45 @@
import {
createContext,
type FC,
type PropsWithChildren,
useContext,
useState,
} from "react";
type ExpiredFileIdsContextValue = {
hasExpired: (fileId: string) => boolean;
markExpired: (fileId: string) => void;
};
const ExpiredFileIdsContext = createContext<ExpiredFileIdsContextValue>({
hasExpired: () => false,
markExpired: () => {},
});
export const ExpiredFileIdsProvider: FC<PropsWithChildren> = ({ children }) => {
const [expiredFileIds, setExpiredFileIds] = useState<Set<string>>(
() => new Set(),
);
return (
<ExpiredFileIdsContext.Provider
value={{
hasExpired: (fileId) => expiredFileIds.has(fileId),
markExpired: (fileId) => {
setExpiredFileIds((previous) => {
if (previous.has(fileId)) {
return previous;
}
const next = new Set(previous);
next.add(fileId);
return next;
});
},
}}
>
{children}
</ExpiredFileIdsContext.Provider>
);
};
export const useExpiredFileIds = () => useContext(ExpiredFileIdsContext);
@@ -1,6 +1,19 @@
import { FileTextIcon } from "lucide-react";
import { type FC, Fragment, useEffect, useRef, useState } from "react";
import { AlertTriangleIcon, FileTextIcon } from "lucide-react";
import { type FC, Fragment, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { useLatestAbortController } from "../../hooks/useLatestAbortController";
import {
type AttachmentFailure,
attachmentFailureFromError,
getChatFileURL,
isAbortError,
probeAttachmentFailure,
} from "../../utils/chatAttachments";
import {
decodeInlineTextAttachment,
fetchTextAttachmentContent,
@@ -9,12 +22,37 @@ import {
import { ImageThumbnail } from "../AgentChatInput";
import { Message, MessageContent } from "../ChatElements";
import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode";
import { useExpiredFileIds } from "./ExpiredFileIdsContext";
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;
@@ -47,54 +85,204 @@ const TextAttachmentButton: FC<{
fileId: string;
onPreview?: (content: string) => void;
}> = ({ fileId, onPreview }) => {
const { hasExpired, markExpired } = useExpiredFileIds();
const isKnownExpired = hasExpired(fileId);
const [content, setContent] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const [failureState, setFailureState] = useState<AttachmentFailureState>(
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
);
const request = useLatestAbortController(isKnownExpired);
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
if (failureState.kind === "expired" || isKnownExpired) {
return (
<AttachmentFallbackTile
state={{ kind: "expired" }}
labels={textAttachmentFailureLabels}
className="h-16 w-28"
/>
);
}
if (failureState.kind === "failed") {
return (
<AttachmentFallbackTile
state={failureState}
labels={textAttachmentFailureLabels}
className="h-16 w-28"
/>
);
}
return (
<InlineTextAttachmentButton
content={content ?? "Pasted text"}
isPlaceholder={content === null}
onPreview={async () => {
onPreview={() => {
if (content !== null) {
onPreview?.(content);
return;
}
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const controller = request.start();
let fetchedContent: string;
try {
fetchedContent = await fetchTextAttachmentContent(
fileId,
controller.signal,
);
} catch (error) {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Failed to load text attachment:", error);
return;
}
if (controllerRef.current === controller) {
controllerRef.current = null;
}
setContent(fetchedContent);
onPreview?.(fetchedContent);
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 = (
<div
role="img"
aria-label={label}
className={cn(
"flex flex-col items-center justify-center gap-1 rounded-md border border-border-default bg-surface-tertiary px-1 text-center text-2xs text-content-secondary",
className,
)}
>
<AlertTriangleIcon
className="size-icon-sm shrink-0 text-content-warning"
aria-hidden="true"
/>
<span className="leading-tight">{label}</span>
</div>
);
// 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 (
<Tooltip>
<TooltipTrigger asChild>{tile}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{tooltipBody}
</TooltipContent>
</Tooltip>
);
};
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<AttachmentFailureState>(
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
);
const probeRequest = useLatestAbortController(isKnownExpired);
if (failureState.kind === "expired" || isKnownExpired) {
return (
<AttachmentFallbackTile
state={{ kind: "expired" }}
labels={imageAttachmentFailureLabels}
/>
);
}
if (failureState.kind === "failed") {
return (
<AttachmentFallbackTile
state={failureState}
labels={imageAttachmentFailureLabels}
/>
);
}
return (
<button
type="button"
aria-label="View image"
className="inline-block rounded-md border-0 bg-transparent p-0"
onClick={(event) => {
event.stopPropagation();
onImageClick?.(source.src);
}}
>
<ImageThumbnail
previewUrl={source.src}
name="Attached image"
className="cursor-pointer transition-opacity hover:opacity-80"
onError={() => {
if (source.kind !== "file") {
setFailureState({ kind: "failed" });
return;
}
if (hasExpired(source.fileId)) {
setFailureState({ kind: "expired" });
return;
}
const controller = probeRequest.start();
// Optimistically swap to the generic failure tile. The
// probe will either upgrade it to "expired" or fill in
// a detail; showing a tile without a label flash is
// preferable to leaving the broken-image icon up.
setFailureState({ kind: "failed" });
void probeAttachmentFailure(source.src, controller.signal)
.then((reason) => {
if (!probeRequest.clear(controller)) {
return;
}
if (reason.kind === "expired") {
markExpired(source.fileId);
}
setFailureState(reason);
})
.catch((error) => {
if (!probeRequest.clear(controller)) {
return;
}
if (isAbortError(error)) {
return;
}
setFailureState(attachmentFailureFromError(error));
});
}}
/>
</button>
);
};
export const FileBlock: FC<{
block: UserFileRenderBlock;
onImageClick?: (src: string) => void;
@@ -121,26 +309,17 @@ export const FileBlock: FC<{
if (!block.media_type.startsWith("image/")) {
return null;
}
const src = block.file_id
? `/api/experimental/chats/files/${block.file_id}`
: `data:${block.media_type};base64,${block.data}`;
return (
<button
type="button"
aria-label="View image"
className="inline-block rounded-md border-0 bg-transparent p-0"
onClick={(event) => {
event.stopPropagation();
onImageClick?.(src);
}}
>
<ImageThumbnail
previewUrl={src}
name="Attached image"
className="cursor-pointer transition-opacity hover:opacity-80"
/>
</button>
);
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 <ChatImageBlock source={source} onImageClick={onImageClick} />;
};
const renderUserInlineBlock = (block: UserInlineRenderBlock, index: number) => {
@@ -1,6 +1,7 @@
import { ImageOffIcon, PlayIcon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { getChatFileURL } from "../../../utils/chatAttachments";
import { VideoLightbox } from "../../VideoLightbox";
import { DEFAULT_ASPECT, PREVIEW_HEIGHT } from "./previewConstants";
@@ -35,8 +36,7 @@ export const RecordingPreview: React.FC<RecordingPreviewProps> = ({
// component remounts and resets its internal error state.
const [lightboxKey, setLightboxKey] = useState(0);
const videoSrc =
srcOverride ?? `/api/experimental/chats/files/${recordingFileId}`;
const videoSrc = srcOverride ?? getChatFileURL(recordingFileId);
return (
<div
@@ -50,10 +50,7 @@ export const RecordingPreview: React.FC<RecordingPreviewProps> = ({
</div>
) : thumbnailFileId ? (
<img
src={
thumbnailSrcOverride ??
`/api/experimental/chats/files/${thumbnailFileId}`
}
src={thumbnailSrcOverride ?? getChatFileURL(thumbnailFileId)}
alt="Recording thumbnail"
className="h-full w-full pointer-events-none object-cover"
onError={() => setThumbnailError(true)}
@@ -5,6 +5,7 @@ import type * as TypesGen from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { useFileAttachments } from "../hooks/useFileAttachments";
import { getChatFileURL } from "../utils/chatAttachments";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import {
AgentChatInput,
@@ -331,10 +332,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
setAttachments(files);
setPreviewUrls(
new Map(
files.map((f, i) => [
f,
`/api/experimental/chats/files/${fileBlocks[i].file_id}`,
]),
files.map((f, i) => [f, getChatFileURL(fileBlocks[i].file_id ?? "")]),
),
);
const newUploadStates = new Map<File, UploadState>();
@@ -8,6 +8,7 @@ import {
import { API } from "#/api/api";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import type { UploadState } from "../components/AgentChatInput";
import { getChatFileURL } from "../utils/chatAttachments";
/** @internal Exported for testing. */
export const persistedAttachmentsStorageKey = "agents.persisted-attachments";
@@ -88,7 +89,7 @@ function restorePersistedAttachments(currentOrgId: string): {
attachments.push(file);
uploadStates.set(file, { status: "uploaded", fileId: p.fileId });
if (p.fileType.startsWith("image/")) {
previewUrls.set(file, `/api/experimental/chats/files/${p.fileId}`);
previewUrls.set(file, getChatFileURL(p.fileId));
}
}
return { attachments, uploadStates, previewUrls };
@@ -236,7 +237,7 @@ export function useFileAttachments(
// intentionally skip text attachments because the
// composer already has the text content locally.
if (isImage) {
void fetch(`/api/experimental/chats/files/${result.id}`);
void fetch(getChatFileURL(result.id));
}
} catch (err: unknown) {
const message = getErrorMessage(err, "Upload failed");
@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
type LatestAbortController = {
start: () => AbortController;
clear: (controller: AbortController) => boolean;
abort: () => void;
};
export const useLatestAbortController = (
shouldAbortCurrentRequest = false,
): LatestAbortController => {
const controllerRef = useRef<AbortController | null>(null);
const abort = () => {
controllerRef.current?.abort();
controllerRef.current = null;
};
useEffect(() => {
return () => {
controllerRef.current?.abort();
controllerRef.current = null;
};
}, []);
useEffect(() => {
if (shouldAbortCurrentRequest) {
controllerRef.current?.abort();
controllerRef.current = null;
}
}, [shouldAbortCurrentRequest]);
return {
start: () => {
abort();
const controller = new AbortController();
controllerRef.current = controller;
return controller;
},
clear: (controller) => {
if (controllerRef.current !== controller) {
return false;
}
controllerRef.current = null;
return true;
},
abort,
};
};
@@ -0,0 +1,62 @@
import { isApiErrorResponse } from "#/api/errors";
const undisplayableAttachmentDetail = "File exists but could not be displayed.";
export type AttachmentFailure =
| { kind: "expired" }
| { kind: "failed"; detail?: string };
export const getChatFileURL = (fileId: string) =>
`/api/experimental/chats/files/${fileId}`;
export const isAbortError = (error: unknown): error is Error =>
error instanceof Error && error.name === "AbortError";
export const attachmentFailureFromError = (
error: unknown,
): AttachmentFailure => ({
kind: "failed",
detail: error instanceof Error ? error.message : undefined,
});
/**
* Converts a chat attachment HTTP response into an availability classification.
*/
export async function classifyAttachmentFailureResponse(
response: Response,
): Promise<AttachmentFailure> {
if (response.status === 404) {
return { kind: "expired" };
}
if (response.ok) {
return { kind: "failed", detail: undisplayableAttachmentDetail };
}
// Prefer the API's structured error message (coderd returns
// codersdk.Response { message, detail }). Fall back to the status
// line when the body isn't JSON, for example when a proxy inserted
// an HTML page, so the tooltip still surfaces something concrete.
let detail = response.statusText
? `${response.status} ${response.statusText}`
: `HTTP ${response.status}`;
try {
const body: unknown = await response.json();
if (isApiErrorResponse(body) && body.message.trim()) {
detail = body.message;
}
} catch {
// Body wasn't JSON; stick with the status line.
}
return { kind: "failed", detail };
}
/**
* Performs a follow-up fetch for an attachment that failed to render locally.
*/
export async function probeAttachmentFailure(
src: string,
signal?: AbortSignal,
): Promise<AttachmentFailure> {
const response = await fetch(src, { signal });
return classifyAttachmentFailureResponse(response);
}
@@ -1,9 +1,19 @@
import {
type AttachmentFailure,
classifyAttachmentFailureResponse,
getChatFileURL,
} from "./chatAttachments";
/**
* Roughly 1-2 lines of typical code at normal terminal width.
* Short enough to fit in attachment previews without excessive wrapping.
*/
const TEXT_ATTACHMENT_PREVIEW_LENGTH = 150;
type TextAttachmentLoadResult =
| { kind: "loaded"; content: string }
| AttachmentFailure;
export function formatTextAttachmentPreview(
text: string,
maxLength = TEXT_ATTACHMENT_PREVIEW_LENGTH,
@@ -35,12 +45,10 @@ export function decodeInlineTextAttachment(content: string): string {
export async function fetchTextAttachmentContent(
fileId: string,
signal?: AbortSignal,
): Promise<string> {
const response = await fetch(`/api/experimental/chats/files/${fileId}`, {
signal,
});
if (!response.ok) {
throw new Error("Failed to fetch file");
): Promise<TextAttachmentLoadResult> {
const response = await fetch(getChatFileURL(fileId), { signal });
if (response.ok) {
return { kind: "loaded", content: await response.text() };
}
return response.text();
return classifyAttachmentFailureResponse(response);
}