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
+75 -19
View File
@@ -66,8 +66,9 @@ func ExtractFileID(raw json.RawMessage) (uuid.UUID, error) {
// ConvertMessagesWithFiles converts persisted chat messages into LLM
// prompt messages, resolving user file references via the provided
// resolver. Persisted file references without bytes are omitted from
// the prompt instead of being replayed back to the model.
// resolver. Missing-data placeholders are emitted only for replayed
// user uploads; assistant-side and tool-side file metadata without
// bytes is dropped from later model turns.
func ConvertMessagesWithFiles(
ctx context.Context,
messages []database.ChatMessage,
@@ -76,8 +77,8 @@ func ConvertMessagesWithFiles(
) ([]fantasy.Message, error) {
// Phase 1: Parse all messages via ParseContent (→ SDK parts)
// and collect file_id references from user messages for batch
// resolution. Assistant-side file attachments remain persisted chat
// metadata and are intentionally not replayed to the model.
// resolution. Assistant-side file attachments remain persisted
// chat metadata and are intentionally not replayed to the model.
type parsedMessage struct {
role codersdk.ChatMessageRole
parts []codersdk.ChatMessagePart
@@ -124,6 +125,10 @@ func ConvertMessagesWithFiles(
return nil, xerrors.Errorf("resolve chat files: %w", err)
}
}
userMissingFilePolicy := dropMissingFiles
if resolver != nil {
userMissingFilePolicy = placeholderMissingFiles
}
// Phase 3: Build fantasy messages from SDK parts via
// partsToMessageParts. Track tool names for injection.
@@ -144,7 +149,13 @@ func ConvertMessagesWithFiles(
},
})
case codersdk.ChatMessageRoleUser:
userParts := partsToMessageParts(logger, pm.parts, resolved)
userParts := partsToMessageParts(
ctx,
logger,
pm.parts,
resolved,
userMissingFilePolicy,
)
if len(userParts) == 0 {
continue
}
@@ -154,7 +165,7 @@ func ConvertMessagesWithFiles(
})
case codersdk.ChatMessageRoleAssistant:
fantasyParts := normalizeAssistantToolCallInputs(
partsToMessageParts(logger, pm.parts, nil),
partsToMessageParts(ctx, logger, pm.parts, nil, dropMissingFiles),
)
for _, toolCall := range ExtractToolCalls(fantasyParts) {
if toolCall.ToolCallID == "" || strings.TrimSpace(toolCall.ToolName) == "" {
@@ -178,7 +189,7 @@ func ConvertMessagesWithFiles(
}
}
}
toolParts := partsToMessageParts(logger, pm.parts, nil)
toolParts := partsToMessageParts(ctx, logger, pm.parts, nil, dropMissingFiles)
if len(toolParts) == 0 {
continue
}
@@ -1191,6 +1202,25 @@ func formatSyntheticPasteText(name string, body []byte) string {
return sb.String()
}
func formatMissingAttachmentText(mediaType string) string {
const missingAttachmentBody = "[missing-attachment] The user attached a file here, but the content has expired and is no longer available."
const missingAttachmentAction = " If you need to inspect it, ask the user to re-upload."
if parsedMediaType, _, err := mime.ParseMediaType(mediaType); err == nil {
mediaType = parsedMediaType
}
mediaType = strings.TrimSpace(mediaType)
if mediaType == "" || mediaType == "application/octet-stream" {
return missingAttachmentBody + missingAttachmentAction
}
return fmt.Sprintf(
"%s Reported MIME type: %s.%s",
missingAttachmentBody,
mediaType,
missingAttachmentAction,
)
}
// fileReferencePartToText formats a file-reference SDK part as
// plain text for LLM consumption. LLMs don't understand
// file-reference natively, so we convert to a readable text
@@ -1297,14 +1327,23 @@ type persistedMediaResult struct {
Text string `json:"text"`
}
type missingFilePolicy uint8
const (
dropMissingFiles missingFilePolicy = iota
placeholderMissingFiles
)
// partsToMessageParts converts SDK chat message parts into fantasy
// message parts for LLM dispatch. It handles file data injection
// from resolved files, file-reference to text conversion, and
// source part skipping.
// message parts for LLM dispatch. resolved is a lookup map for file
// bytes, and policy controls whether missing file-backed parts are
// dropped or replaced with text placeholders.
func partsToMessageParts(
ctx context.Context,
logger slog.Logger,
parts []codersdk.ChatMessagePart,
resolved map[uuid.UUID]FileData,
policy missingFilePolicy,
) []fantasy.MessagePart {
result := make([]fantasy.MessagePart, 0, len(parts))
for _, part := range parts {
@@ -1345,8 +1384,10 @@ func partsToMessageParts(
data := part.Data
mediaType := part.MediaType
var name string
resolvedFile := false
if part.FileID.Valid {
if fd, ok := resolved[part.FileID.UUID]; ok {
resolvedFile = true
data = fd.Data
name = fd.Name
if mediaType == "" {
@@ -1354,13 +1395,7 @@ func partsToMessageParts(
}
}
}
if len(data) == 0 {
// File parts without bytes are persistence metadata, not
// prompt content. User uploads should have been resolved
// above; assistant tool attachments intentionally are not
// replayed into later model turns.
continue
}
opts := providerMetadataToOptions(logger, part.ProviderMetadata)
// Providers only accept a small set of MIME types in file
// content blocks, typically images and PDFs. A synthetic
// paste sent as a text/plain FilePart is dropped or rejected,
@@ -1369,14 +1404,35 @@ func partsToMessageParts(
if isSyntheticPaste(name, mediaType) {
result = append(result, fantasy.TextPart{
Text: formatSyntheticPasteText(name, data),
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
ProviderOptions: opts,
})
continue
}
if part.FileID.Valid && !resolvedFile {
if policy == placeholderMissingFiles {
logger.Info(ctx,
"chat file unavailable, replacing file part with text placeholder",
slog.F("file_id", part.FileID.UUID),
slog.F("media_type", mediaType),
)
result = append(result, fantasy.TextPart{
Text: formatMissingAttachmentText(mediaType),
ProviderOptions: opts,
})
}
continue
}
if len(data) == 0 {
// File parts without bytes are persistence metadata, empty
// uploads, or provider-invalid prompt content. Unresolved
// file-backed parts are handled above so empty uploads do
// not look expired.
continue
}
result = append(result, fantasy.FilePart{
Data: data,
MediaType: mediaType,
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
ProviderOptions: opts,
})
case codersdk.ChatMessagePartTypeFileReference:
// LLMs don't understand file-reference natively.
@@ -191,6 +191,171 @@ func TestConvertMessagesWithFiles_ResolvesFileData(t *testing.T) {
require.Equal(t, "image/png", filePart.MediaType)
}
func TestConvertMessagesWithFiles_MissingFileBackedAttachmentBecomesTextPart(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mediaType string
expectedText string
}{
{
name: "missing image file",
mediaType: "image/png",
expectedText: "[missing-attachment] The user attached a file here, but the content has expired and is no longer available. " +
"Reported MIME type: image/png. If you need to inspect it, ask the user to re-upload.",
},
{
name: "generic mime omits mime sentence",
mediaType: "application/octet-stream",
expectedText: "[missing-attachment] The user attached a file here, but the content has expired and is no longer available. If you need to inspect it, ask the user to re-upload.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fileID := uuid.New()
rawContent := mustJSON(t, []json.RawMessage{
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"media_type": tt.mediaType,
"file_id": fileID.String(),
},
}),
})
resolver := func(_ context.Context, _ []uuid.UUID) (map[uuid.UUID]chatprompt.FileData, error) {
return map[uuid.UUID]chatprompt.FileData{}, nil
}
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
}},
resolver,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Len(t, prompt, 1)
require.Len(t, prompt[0].Content, 1)
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[0])
require.True(t, ok, "expected TextPart")
require.Equal(t, tt.expectedText, textPart.Text)
})
}
}
func TestConvertMessagesWithFiles_ResolvedZeroByteFileIsDropped(t *testing.T) {
t.Parallel()
fileID := uuid.New()
rawContent := mustJSON(t, []json.RawMessage{
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"file_id": fileID.String(),
},
}),
})
resolver := func(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]chatprompt.FileData, error) {
result := make(map[uuid.UUID]chatprompt.FileData)
for _, id := range ids {
if id == fileID {
result[id] = chatprompt.FileData{
Data: []byte{},
MediaType: "text/plain",
Name: "empty.txt",
}
}
}
return result, nil
}
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
}},
resolver,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Empty(t, prompt)
}
func TestConvertMessagesWithFiles_MixedResolvedAndMissingFilePartsInSingleMessage(t *testing.T) {
t.Parallel()
resolvedFileID := uuid.New()
missingFileID := uuid.New()
resolvedData := []byte("resolved-image-data")
rawContent := mustJSON(t, []json.RawMessage{
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"media_type": "image/png",
"file_id": resolvedFileID.String(),
},
}),
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"media_type": "application/pdf",
"file_id": missingFileID.String(),
},
}),
})
resolver := func(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]chatprompt.FileData, error) {
result := make(map[uuid.UUID]chatprompt.FileData)
for _, id := range ids {
if id == resolvedFileID {
result[id] = chatprompt.FileData{
Data: resolvedData,
MediaType: "image/png",
Name: "resolved.png",
}
}
}
return result, nil
}
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
}},
resolver,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Len(t, prompt, 1)
require.Equal(t, fantasy.MessageRoleUser, prompt[0].Role)
require.Len(t, prompt[0].Content, 2)
filePart, ok := fantasy.AsMessagePart[fantasy.FilePart](prompt[0].Content[0])
require.True(t, ok, "expected first part to stay a FilePart")
require.Equal(t, resolvedData, filePart.Data)
require.Equal(t, "image/png", filePart.MediaType)
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[1])
require.True(t, ok, "expected missing second part to become a TextPart")
require.Equal(t,
"[missing-attachment] The user attached a file here, but the content has expired and is no longer available. "+
"Reported MIME type: application/pdf. If you need to inspect it, ask the user to re-upload.",
textPart.Text,
)
}
func TestConvertMessagesWithFiles_BackwardCompat(t *testing.T) {
t.Parallel()
@@ -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);
}