diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index af05de99ee..43613c3693 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -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. diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index 4bc9f50094..6750d489ed 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -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() diff --git a/site/src/pages/AgentsPage/components/AttachmentPreview.tsx b/site/src/pages/AgentsPage/components/AttachmentPreview.tsx index 9cc2f1101d..150f2d0dd9 100644 --- a/site/src/pages/AgentsPage/components/AttachmentPreview.tsx +++ b/site/src/pages/AgentsPage/components/AttachmentPreview.tsx @@ -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; +}> = ({ previewUrl, name, className, onError }) => ( {name} ); @@ -54,11 +58,7 @@ export const AttachmentPreview: FC<{ onTextPreview, onInlineText, }) => { - const textAttachmentLoadControllerRef = useRef(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 => { - 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; } }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 553be086a9..ce1cfc0345 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -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([ +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([ [ "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(); + +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: "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, + 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, + label: string, +) => { + await waitFor(() => + expect(canvas.getByRole("img", { name: label })).toHaveAttribute( + "data-state", + ), + ); + return canvas.getByRole("img", { name: label }); +}; + const defaultArgs: Omit< React.ComponentProps, "parsedMessages" @@ -119,7 +278,8 @@ const meta: Meta = { ), ], 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" }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 73b6a0c1e8..3185079aa2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -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( : undefined; return ( -
- {parsedMessages.map(({ message, parsed }, msgIdx) => { - if (message.role === "user") { + +
+ {parsedMessages.map(({ message, parsed }, msgIdx) => { + if (message.role === "user") { + return ( + + ); + } + // 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 ( - ); - } - // 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 ( - - ); - })} -
+ })} +
+ ); }, ); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ExpiredFileIdsContext.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ExpiredFileIdsContext.tsx new file mode 100644 index 0000000000..cd98c70632 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/ExpiredFileIdsContext.tsx @@ -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({ + hasExpired: () => false, + markExpired: () => {}, +}); + +export const ExpiredFileIdsProvider: FC = ({ children }) => { + const [expiredFileIds, setExpiredFileIds] = useState>( + () => new Set(), + ); + + return ( + expiredFileIds.has(fileId), + markExpired: (fileId) => { + setExpiredFileIds((previous) => { + if (previous.has(fileId)) { + return previous; + } + const next = new Set(previous); + next.add(fileId); + return next; + }); + }, + }} + > + {children} + + ); +}; + +export const useExpiredFileIds = () => useContext(ExpiredFileIdsContext); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx index 321f9634c2..6e3efc08fa 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx @@ -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(null); - const controllerRef = useRef(null); + const [failureState, setFailureState] = useState( + () => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }), + ); + const request = useLatestAbortController(isKnownExpired); - useEffect(() => { - return () => controllerRef.current?.abort(); - }, []); + if (failureState.kind === "expired" || isKnownExpired) { + return ( + + ); + } + if (failureState.kind === "failed") { + return ( + + ); + } return ( { + 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 = ( +
+
+ ); + + // 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; @@ -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 ( - - ); + 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) => { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/RecordingPreview.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/RecordingPreview.tsx index a984ac439b..362107096f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/RecordingPreview.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/RecordingPreview.tsx @@ -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 = ({ // 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 (
= ({
) : thumbnailFileId ? ( Recording thumbnail setThumbnailError(true)} diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 8ea162041f..426544cc4c 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -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 = ({ 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(); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index fb79db924c..d91411f5ad 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -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"); diff --git a/site/src/pages/AgentsPage/hooks/useLatestAbortController.ts b/site/src/pages/AgentsPage/hooks/useLatestAbortController.ts new file mode 100644 index 0000000000..b985eb2acd --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useLatestAbortController.ts @@ -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(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, + }; +}; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts new file mode 100644 index 0000000000..d7fe0ef226 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -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 { + 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 { + const response = await fetch(src, { signal }); + return classifyAttachmentFailureResponse(response); +} diff --git a/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts b/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts index 846a9cbbec..e1b7fbb529 100644 --- a/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts +++ b/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts @@ -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 { - const response = await fetch(`/api/experimental/chats/files/${fileId}`, { - signal, - }); - if (!response.ok) { - throw new Error("Failed to fetch file"); +): Promise { + const response = await fetch(getChatFileURL(fileId), { signal }); + if (response.ok) { + return { kind: "loaded", content: await response.text() }; } - return response.text(); + return classifyAttachmentFailureResponse(response); }