feat(site): display file attachments in chat UI (#24281)

Renders the durable file attachments introduced in #24280 in the chat
interface. Without this, attachments were stored and served correctly
but the UI showed raw file parts with no previews or download UX.

Every attachment gets a download affordance, split into three rendering
tiers:

- **Images** — thumbnail with a hover/focus overlay containing a
download link. `onFocusCapture`/`onBlurCapture` with
`contains(relatedTarget)` keeps the overlay open while tabbing between
the image and its download link.
- **Text-like files** (`text/*`, `application/json`) — expandable
preview button with loading + error-with-retry states and the same
download overlay. Preview fetches throw a typed
`FetchTextAttachmentError` with a `.status` field instead of a
stringly-typed error.
- **Everything else** — compact `FileCard` with extension badge,
filename, and download link.

User-side and assistant-side rendering now share `AttachmentBlocks.tsx`
(`AttachmentPreviewFrame`, `TextAttachmentButton`,
`ImageAttachmentButton`, `FileCard`, plus
`getAttachmentHref`/`getAttachmentName`) instead of two near-duplicate
implementations. The text-attachment overlay anchors to the preview
surface so the download button stays pinned even when a loading/error
status line widens the row below.

`ComputerRenderer` detects when a screenshot was stored as a durable
attachment (`attachment_file_id`) and suppresses the stale base64
rendering — the screenshot appears as a proper file part instead.
`ToolLabel` shows the attached filename for `attach_file` tool calls.

Storybook coverage in `ConversationTimeline.stories.tsx` was expanded to
cover every tier (single/multiple images, inline + file-id text, JSON,
download-only files, fetch-failure retry, mixed attachments + file
references) with play-function assertions.

<img width="811" height="150" alt="image"
src="https://github.com/user-attachments/assets/27c71081-3502-4e80-92a7-d8adf1ff9323"
/>



## Cleanup

Per Mathias' post-merge suggestion on #24280, this PR also relocates
`coderd/chatfiles` → `coderd/x/chatfiles` so the durable-attachment
helpers live beside the rest of the `chatd` experimental surface.

Closes CODAGT-91
This commit is contained in:
Ethan
2026-04-22 20:11:53 +10:00
committed by GitHub
parent ad1906589d
commit cc4e04afde
26 changed files with 1358 additions and 389 deletions
+1 -1
View File
@@ -28,7 +28,6 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/chatfiles"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
@@ -50,6 +49,7 @@ import (
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/coderd/x/gitsync"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
+1 -1
View File
@@ -23,7 +23,7 @@ func buildAssistantPartsForPersist(
) []codersdk.ChatMessagePart {
parts := make([]codersdk.ChatMessagePart, 0, len(assistantBlocks)+len(toolResults))
for _, block := range assistantBlocks {
part := chatprompt.PartFromContent(block)
part := chatprompt.PartFromContentWithLogger(ctx, logger, block)
if part.ToolName != "" {
if configID, ok := toolNameToConfigID[part.ToolName]; ok {
part.MCPServerConfigID = uuid.NullUUID{UUID: configID, Valid: true}
+3 -2
View File
@@ -6015,7 +6015,7 @@ func (p *Server) runChat(
// FOR UPDATE lock is held only for the INSERT statements.
// Marshaling is pure CPU work with no database dependency.
assistantParts := buildAssistantPartsForPersist(
ctx,
persistCtx,
p.logger,
assistantBlocks,
toolResults,
@@ -6035,7 +6035,7 @@ func (p *Server) runChat(
toolResultContents := make([]pqtype.NullRawMessage, len(toolResults))
for i, tr := range toolResults {
trPart := chatprompt.PartFromContent(tr)
trPart := chatprompt.PartFromContentWithLogger(ctx, logger, tr)
if trPart.ToolName != "" {
if configID, ok := toolNameToConfigID[trPart.ToolName]; ok {
trPart.MCPServerConfigID = uuid.NullUUID{UUID: configID, Valid: true}
@@ -6496,6 +6496,7 @@ func (p *Server) runChat(
}
p.publishMessagePart(chat.ID, role, part)
},
Logger: logger,
Compaction: compactionOptions,
ReloadMessages: func(reloadCtx context.Context) ([]fantasy.Message, error) {
reloadedMsgs, err := p.db.GetChatMessagesForPromptByChatID(reloadCtx, chat.ID)
+32 -1
View File
@@ -19,11 +19,13 @@ import (
"charm.land/fantasy/schema"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
@@ -146,6 +148,7 @@ type RunOptions struct {
role codersdk.ChatMessageRole,
part codersdk.ChatMessagePart,
)
Logger slog.Logger
Compaction *CompactionOptions
ReloadMessages func(context.Context) ([]fantasy.Message, error)
DisableChainMode func()
@@ -492,7 +495,8 @@ func Run(ctx context.Context, opts RunOptions) error {
// Execute only built-in tools.
toolResults = executeTools(ctx, opts.Tools, opts.ActiveTools, opts.ProviderTools, builtinCalls, opts.Metrics, provider, modelName, opts.BuiltinToolNames, func(tr fantasy.ToolResultContent, completedAt time.Time) {
recordToolResultTimestamp(&result, tr.ToolCallID, completedAt)
ssePart := chatprompt.PartFromContent(tr)
publishToolAttachments(ctx, opts.Logger, tr, completedAt, publishMessagePart)
ssePart := chatprompt.PartFromContentWithLogger(ctx, opts.Logger, tr)
ssePart.CreatedAt = &completedAt
publishMessagePart(codersdk.ChatMessageRoleTool, ssePart)
})
@@ -1545,6 +1549,33 @@ func recordToolResultTimestamp(result *stepResult, toolCallID string, ts time.Ti
result.toolResultCreatedAt[toolCallID] = ts
}
func publishToolAttachments(
ctx context.Context,
logger slog.Logger,
tr fantasy.ToolResultContent,
createdAt time.Time,
publishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart),
) {
attachments, err := chattool.AttachmentsFromMetadata(tr.ClientMetadata)
if err != nil {
logger.Warn(ctx, "skipping malformed tool attachment metadata",
slog.F("tool_name", tr.ToolName),
slog.F("tool_call_id", tr.ToolCallID),
slog.Error(err),
)
return
}
for _, attachment := range attachments {
filePart := codersdk.ChatMessageFile(
attachment.FileID,
attachment.MediaType,
attachment.Name,
)
filePart.CreatedAt = &createdAt
publishMessagePart(codersdk.ChatMessageRoleAssistant, filePart)
}
}
func extractContextLimit(metadata fantasy.ProviderMetadata) sql.NullInt64 {
if len(metadata) == 0 {
return sql.NullInt64{}
+80 -14
View File
@@ -16,6 +16,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
)
@@ -702,6 +703,29 @@ func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isEr
// PartFromContent converts fantasy content into a SDK chat message
// part, preserving ProviderMetadata and ProviderExecuted fields.
func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
return sdkPartFromContent(block, nil)
}
// PartFromContentWithLogger is for call sites that can surface malformed
// attachment metadata immediately instead of dropping it silently.
func PartFromContentWithLogger(
ctx context.Context,
logger slog.Logger,
block fantasy.Content,
) codersdk.ChatMessagePart {
return sdkPartFromContent(block, func(content fantasy.ToolResultContent, err error) {
logger.Warn(ctx, "skipping malformed tool attachment metadata",
slog.F("tool_name", content.ToolName),
slog.F("tool_call_id", content.ToolCallID),
slog.Error(err),
)
})
}
func sdkPartFromContent(
block fantasy.Content,
logMalformedAttachmentMetadata func(fantasy.ToolResultContent, error),
) codersdk.ChatMessagePart {
switch value := block.(type) {
case fantasy.TextContent:
return codersdk.ChatMessagePart{
@@ -776,9 +800,9 @@ func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
}
case fantasy.ToolResultContent:
return toolResultContentToPart(value)
return toolResultContentToPart(value, logMalformedAttachmentMetadata)
case *fantasy.ToolResultContent:
return toolResultContentToPart(*value)
return toolResultContentToPart(*value, logMalformedAttachmentMetadata)
default:
return codersdk.ChatMessagePart{}
}
@@ -794,7 +818,10 @@ func ToolResultToPart(toolCallID, toolName string, result json.RawMessage, isErr
// toolResultContentToPart converts a fantasy ToolResultContent into a
// ChatMessagePart.
func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMessagePart {
func toolResultContentToPart(
content fantasy.ToolResultContent,
logMalformedAttachmentMetadata func(fantasy.ToolResultContent, error),
) codersdk.ChatMessagePart {
var result json.RawMessage
var isError bool
var isMedia bool
@@ -820,11 +847,24 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes
}
case fantasy.ToolResultOutputContentMedia:
isMedia = true
result, _ = json.Marshal(persistedMediaResult{
persisted := persistedMediaResult{
Data: output.Data,
MimeType: output.MediaType,
Text: output.Text,
})
}
// Tool renderers only receive the persisted result JSON, while
// ClientMetadata is consumed later to append sibling file parts.
// Mirror attachment identity here so promoted media can be
// recognized as the same durable attachment downstream.
if attachment, ok := matchingAttachmentForMedia(
content,
output.MediaType,
logMalformedAttachmentMetadata,
); ok {
persisted.AttachmentFileID = attachment.FileID.String()
persisted.AttachmentName = attachment.Name
}
result, _ = json.Marshal(persisted)
default:
result = []byte(`{}`)
}
@@ -835,6 +875,26 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes
return part
}
func matchingAttachmentForMedia(
content fantasy.ToolResultContent,
mediaType string,
logMalformedAttachmentMetadata func(fantasy.ToolResultContent, error),
) (chattool.AttachmentMetadata, bool) {
attachments, err := chattool.AttachmentsFromMetadata(content.ClientMetadata)
if err != nil {
if logMalformedAttachmentMetadata != nil {
logMalformedAttachmentMetadata(content, err)
}
return chattool.AttachmentMetadata{}, false
}
for _, attachment := range attachments {
if attachment.MediaType == mediaType {
return attachment, true
}
}
return chattool.AttachmentMetadata{}, false
}
// Keep in sync with coderd/x/chatd/subagent.go.
func isSubagentLifecycleToolName(name string) bool {
switch name {
@@ -1267,10 +1327,11 @@ func toolResultPartToMessagePart(logger slog.Logger, part codersdk.ChatMessagePa
// IsError takes precedence and is handled above.
// Detect media content flagged by toolResultContentToPart.
// Screenshots from the computer use tool are stored as
// {"data":"<base64>","mime_type":"image/png","text":"..."}.
// Without this detection, the entire base64 payload is sent
// as text tokens, which quickly exceeds the context limit
// on follow-up messages.
// {"data":"<base64>","mime_type":"image/png","text":"..."}
// with optional attachment identity fields when the same image
// was also promoted into a durable file part. Without this
// detection, the entire base64 payload is sent as text tokens,
// which quickly exceeds the context limit on follow-up messages.
if part.IsMedia {
var media persistedMediaResult
unmarshalErr := json.Unmarshal(part.Result, &media)
@@ -1319,12 +1380,17 @@ func toolResultPartToMessagePart(logger slog.Logger, part codersdk.ChatMessagePa
// cannot drift.
//
// The "mime_type" key intentionally diverges from the fantasy
// struct tag (json:"media_type"). Do not change it without
// updating both paths.
// struct tag (json:"media_type"). Optional attachment identity
// fields are UI hints only. They let the frontend recognize when the
// same media was also promoted into a durable file part, but the prompt
// reconstruction path must continue to ignore them. Keep additions
// backwards-compatible because existing rows may omit these fields.
type persistedMediaResult struct {
Data string `json:"data"`
MimeType string `json:"mime_type"`
Text string `json:"text"`
Data string `json:"data"`
MimeType string `json:"mime_type"`
Text string `json:"text"`
AttachmentFileID string `json:"attachment_file_id,omitempty"`
AttachmentName string `json:"attachment_name,omitempty"`
}
type missingFilePolicy uint8
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -2384,6 +2385,103 @@ func TestMediaToolResultRoundTrip(t *testing.T) {
require.Equal(t, mimeType, mediaOutput.MediaType)
})
t.Run("MediaResultCarriesPromotedAttachmentMetadata", func(t *testing.T) {
t.Parallel()
const callID = "call-screenshot-promoted"
const toolName = "computer"
const mimeType = "image/png"
const attachmentName = "screenshot-2026-04-21T00-00-00Z.png"
attachmentID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
response := chattool.WithAttachments(
fantasy.NewImageResponse([]byte(imageData), mimeType),
chattool.AttachmentMetadata{
FileID: attachmentID,
MediaType: mimeType,
Name: attachmentName,
},
)
sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{
ToolCallID: callID,
ToolName: toolName,
ClientMetadata: response.Metadata,
Result: fantasy.ToolResultOutputContentMedia{
Data: imageData,
MediaType: mimeType,
},
})
var persisted struct {
Data string `json:"data"`
MimeType string `json:"mime_type"`
Text string `json:"text"`
AttachmentFileID string `json:"attachment_file_id"`
AttachmentName string `json:"attachment_name"`
}
require.NoError(t, json.Unmarshal(sdkPart.Result, &persisted))
require.Equal(t, imageData, persisted.Data)
require.Equal(t, mimeType, persisted.MimeType)
require.Equal(t, attachmentID.String(), persisted.AttachmentFileID)
require.Equal(t, attachmentName, persisted.AttachmentName)
chat := insertPair(t, callID, toolName, []codersdk.ChatMessagePart{sdkPart})
prompt := loadPrompt(t, chat)
require.Len(t, prompt, 2)
resultPart, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](prompt[1].Content[0])
require.True(t, ok, "expected ToolResultPart")
mediaOutput, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](resultPart.Output)
require.True(t, ok, "expected ToolResultOutputContentMedia, got %T", resultPart.Output)
require.Equal(t, imageData, mediaOutput.Data)
require.Equal(t, mimeType, mediaOutput.MediaType)
})
t.Run("MediaResultUsesMatchingAttachmentMetadata", func(t *testing.T) {
t.Parallel()
const callID = "call-screenshot-matching-attachment"
const toolName = "computer"
const mimeType = "image/png"
const attachmentName = "screenshot-2026-04-21T00-00-01Z.png"
mismatchedAttachmentID := uuid.MustParse("11111111-2222-3333-4444-555555555555")
matchingAttachmentID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-ffffffffffff")
response := chattool.WithAttachments(
fantasy.NewImageResponse([]byte(imageData), mimeType),
chattool.AttachmentMetadata{
FileID: mismatchedAttachmentID,
MediaType: "application/pdf",
Name: "report.pdf",
},
chattool.AttachmentMetadata{
FileID: matchingAttachmentID,
MediaType: mimeType,
Name: attachmentName,
},
)
sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{
ToolCallID: callID,
ToolName: toolName,
ClientMetadata: response.Metadata,
Result: fantasy.ToolResultOutputContentMedia{
Data: imageData,
MediaType: mimeType,
},
})
var persisted struct {
AttachmentFileID string `json:"attachment_file_id"`
AttachmentName string `json:"attachment_name"`
}
require.NoError(t, json.Unmarshal(sdkPart.Result, &persisted))
require.Equal(t, matchingAttachmentID.String(), persisted.AttachmentFileID)
require.Equal(t, attachmentName, persisted.AttachmentName)
})
t.Run("MediaResultWithText", func(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -11,10 +11,10 @@ import (
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/chatfiles"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
+1 -1
View File
@@ -6,9 +6,9 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/chatfiles"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/codersdk"
)
+1 -1
View File
@@ -8,10 +8,10 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/coder/coder/v2/coderd/chatfiles"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/codersdk"
)
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/chatfiles"
"github.com/coder/coder/v2/coderd/x/chatfiles"
)
func TestDetectMediaType_WebP(t *testing.T) {
@@ -164,7 +164,7 @@ export const TextAttachment: Story = {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const textCard = await canvas.findByRole("button", {
name: "View text attachment",
name: "View clipboard.txt",
});
expect(textCard).toHaveTextContent(/This is the pasted text content\./i);
await userEvent.click(textCard);
@@ -207,7 +207,7 @@ export const ThreeTextAttachments: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findAllByRole("button", { name: "View text attachment" }),
await canvas.findAllByRole("button", { name: /View paste-[1-3]\.txt/ }),
).toHaveLength(3);
expect(
canvas.getByText(
@@ -269,7 +269,7 @@ export const MixedImageAndText: Story = {
await canvas.findByRole("img", { name: "photo.png" }),
).toBeInTheDocument();
expect(
canvas.getByRole("button", { name: "View text attachment" }),
canvas.getByRole("button", { name: "View clipboard.txt" }),
).toBeInTheDocument();
},
};
@@ -1,5 +1,6 @@
import { AlertTriangleIcon, ClipboardPasteIcon, XIcon } from "lucide-react";
import type { FC, ReactEventHandler } from "react";
import { toast } from "sonner";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
@@ -12,6 +13,7 @@ import { isAbortError } from "../utils/chatAttachments";
import {
fetchTextAttachmentContent,
formatTextAttachmentPreview,
getTextAttachmentErrorMessage,
} from "../utils/fetchTextAttachment";
export type UploadState = {
@@ -82,7 +84,10 @@ export const AttachmentPreview: FC<{
if (result.kind === "loaded") {
return result.content;
}
console.warn("Failed to load text attachment:", result);
const resultMessage = getTextAttachmentErrorMessage(result);
if (resultMessage !== null) {
toast.error(resultMessage);
}
return undefined;
} catch (err) {
if (!textAttachmentRequest.clear(controller)) {
@@ -91,7 +96,12 @@ export const AttachmentPreview: FC<{
if (isAbortError(err)) {
return undefined;
}
console.warn("Failed to load text attachment:", err);
const errorMessage = getTextAttachmentErrorMessage(err);
if (errorMessage === null) {
return undefined;
}
console.error("Failed to load text attachment:", err);
toast.error(errorMessage);
return undefined;
}
};
@@ -125,7 +135,7 @@ export const AttachmentPreview: FC<{
) : hasTextAttachment ? (
<button
type="button"
aria-label="View text attachment"
aria-label={`View ${file.name}`}
className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
@@ -159,7 +169,10 @@ export const AttachmentPreview: FC<{
className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label="Paste inline"
>
<ClipboardPasteIcon className="h-3.5 w-3.5" />
<ClipboardPasteIcon
aria-hidden="true"
className="h-3.5 w-3.5"
/>
</button>
)}
{uploadState?.status === "uploading" && (
@@ -191,7 +204,7 @@ export const AttachmentPreview: FC<{
className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label={`Remove ${file.name}`}
>
<XIcon className="h-3.5 w-3.5" />
<XIcon aria-hidden="true" className="h-3.5 w-3.5" />
</button>
</div>
);
@@ -0,0 +1,628 @@
import {
AlertTriangleIcon,
DownloadIcon,
FileIcon,
FileTextIcon,
} from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import { Spinner } from "#/components/Spinner/Spinner";
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,
formatTextAttachmentPreview,
} from "../../utils/fetchTextAttachment";
import { ImageThumbnail } from "../AgentChatInput";
import { useExpiredFileIds } from "./ExpiredFileIdsContext";
import type { RenderBlock } from "./types";
export type PreviewTextAttachment = {
content: string;
fileName?: string;
};
type FileAttachmentBlock = Extract<RenderBlock, { type: "file" }>;
const TEXT_ATTACHMENT_MEDIA_TYPES = new Set([
"text/plain",
"text/markdown",
"text/csv",
"application/json",
]);
const ATTACHMENT_FALLBACK_EXTENSIONS: Record<string, string> = {
"application/json": "json",
"application/octet-stream": "bin",
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
"pptx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"docx",
"application/x-tar": "tar",
"image/jpeg": "jpg",
"text/markdown": "md",
"text/plain": "txt",
};
const sanitizeAttachmentExtension = (value: string): string => {
const sanitized = value
.replace(/[^a-z0-9]/gi, "")
.slice(0, 4)
.toLowerCase();
return sanitized || "file";
};
const getAttachmentExtension = (
block: Pick<FileAttachmentBlock, "media_type" | "name">,
): string => {
const mapped = ATTACHMENT_FALLBACK_EXTENSIONS[block.media_type];
if (mapped) {
return mapped;
}
const trimmedName = block.name?.trim();
if (trimmedName) {
const lastDot = trimmedName.lastIndexOf(".");
// Keep dotfiles like `.env` out of the extension path, while still
// allowing ordinary `name.ext` filenames to contribute a fallback.
if (lastDot > 0 && lastDot < trimmedName.length - 1) {
return sanitizeAttachmentExtension(trimmedName.slice(lastDot + 1));
}
}
const subtype = block.media_type.split("/")[1] ?? "";
if (subtype.endsWith("+json")) {
return "json";
}
return sanitizeAttachmentExtension(subtype);
};
const isTextPreviewAttachmentMediaType = (mediaType: string): boolean =>
TEXT_ATTACHMENT_MEDIA_TYPES.has(mediaType);
const getAttachmentHref = (block: FileAttachmentBlock): string | null => {
if (block.file_id) {
return getChatFileURL(block.file_id);
}
if (block.data) {
return `data:${block.media_type};base64,${block.data}`;
}
return null;
};
const getAttachmentDisplayName = (
block: Pick<FileAttachmentBlock, "media_type" | "name">,
): string => {
const name = block.name?.trim();
if (name) {
return name;
}
if (block.media_type.startsWith("image/")) {
return "Attached image";
}
if (isTextPreviewAttachmentMediaType(block.media_type)) {
return "Pasted text";
}
return "Attached file";
};
const getAttachmentDownloadName = (
block: Pick<FileAttachmentBlock, "media_type" | "name">,
): string => {
const name = block.name?.trim();
if (name) {
return name;
}
const extension = getAttachmentExtension(block);
return extension === "file" ? "attachment" : `attachment.${extension}`;
};
const getAttachmentBadgeLabel = (
block: Pick<FileAttachmentBlock, "media_type" | "name">,
): string => {
const extension = getAttachmentExtension(block);
return extension === "file" ? "" : extension.toUpperCase();
};
const DownloadOverlay: FC<{
href: string;
displayName: string;
downloadName: string;
}> = ({ href, displayName, downloadName }) => (
<a
href={href}
download={downloadName}
onClick={(event) => event.stopPropagation()}
aria-label={`Download ${displayName}`}
className="invisible absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded bg-surface-primary/80 text-content-secondary opacity-0 shadow-sm backdrop-blur-sm transition-opacity hover:text-content-primary group-hover/attachment:visible group-hover/attachment:opacity-100 group-focus-within/attachment:visible group-focus-within/attachment:opacity-100 [@media(hover:none)]:visible [@media(hover:none)]:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
>
<DownloadIcon aria-hidden="true" className="h-3.5 w-3.5" />
</a>
);
const AttachmentPreviewFrame: FC<{
href: string | null;
displayName: string;
downloadName: string;
children: ReactNode;
}> = ({ href, displayName, downloadName, children }) => {
return (
<div className="group/attachment relative inline-flex flex-col items-start">
{children}
{href ? (
<DownloadOverlay
href={href}
displayName={displayName}
downloadName={downloadName}
/>
) : null}
</div>
);
};
type AttachmentFailureState = { kind: "idle" } | AttachmentFailure;
type AttachmentFailureLabels = {
expired: string;
failed: string;
};
const imageAttachmentFailureLabels: AttachmentFailureLabels = {
expired: "Image expired",
failed: "Image failed to load",
};
const textAttachmentFailureLabels: AttachmentFailureLabels = {
expired: "Attachment expired",
failed: "Attachment failed to load",
};
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"
? "Chat attachments are deleted after the retention window set for this deployment."
: state.detail;
if (!tooltipBody) {
return tile;
}
return (
<Tooltip>
<TooltipTrigger asChild>{tile}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{tooltipBody}
</TooltipContent>
</Tooltip>
);
};
const InlineTextAttachmentButton: FC<{
content: string;
fileName?: string;
onPreview?: (attachment: PreviewTextAttachment) => void | Promise<void>;
isPlaceholder?: boolean;
icon?: ReactNode;
}> = ({ content, fileName, onPreview, isPlaceholder, icon }) => {
return (
<button
type="button"
aria-label={
fileName && fileName !== "Pasted text"
? `View ${fileName}`
: "View text attachment"
}
className="inline-flex h-16 max-w-sm items-center gap-2 rounded-md border-0 bg-surface-tertiary px-3 py-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={(event) => {
event.stopPropagation();
void onPreview?.({ content, fileName });
}}
>
{icon ?? (
<FileTextIcon
aria-hidden="true"
className="size-icon-sm shrink-0 text-content-secondary"
/>
)}
<span
className={cn(
"line-clamp-2 min-w-0 text-content-secondary",
isPlaceholder ? "text-sm" : "font-mono text-xs",
)}
>
{isPlaceholder ? content : formatTextAttachmentPreview(content)}
</span>
</button>
);
};
const RemoteTextAttachmentButton: FC<{
fileId: string;
fileName?: string;
frameHref?: string | null;
downloadName: string;
onPreview?: (attachment: PreviewTextAttachment) => void | Promise<void>;
showStatus?: boolean;
}> = ({
fileId,
fileName,
frameHref,
downloadName,
onPreview,
showStatus = false,
}) => {
const { hasExpired, markExpired } = useExpiredFileIds();
const isKnownExpired = hasExpired(fileId);
const [content, setContent] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [failureState, setFailureState] = useState<AttachmentFailureState>(
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
);
const request = useLatestAbortController(isKnownExpired);
if (isKnownExpired) {
return (
<AttachmentFallbackTile
state={{ kind: "expired" }}
labels={textAttachmentFailureLabels}
className="h-16 w-28"
/>
);
}
if (failureState.kind !== "idle") {
return (
<AttachmentFallbackTile
state={failureState}
labels={textAttachmentFailureLabels}
className="h-16 w-28"
/>
);
}
const button = (
<InlineTextAttachmentButton
content={content ?? fileName ?? "Pasted text"}
fileName={fileName}
icon={
showStatus && isLoading ? (
<Spinner
size="sm"
loading
className="shrink-0 text-content-secondary"
/>
) : undefined
}
isPlaceholder={content === null}
onPreview={async () => {
if (isLoading) {
return;
}
if (content !== null) {
void onPreview?.({ content, fileName });
return;
}
const controller = request.start();
setIsLoading(true);
let result: Awaited<ReturnType<typeof fetchTextAttachmentContent>>;
try {
result = await fetchTextAttachmentContent(fileId, controller.signal);
} catch (error) {
if (!request.clear(controller)) {
return;
}
setIsLoading(false);
if (isAbortError(error)) {
return;
}
console.warn("Failed to load text attachment:", error);
setFailureState(attachmentFailureFromError(error));
return;
}
if (!request.clear(controller)) {
return;
}
setIsLoading(false);
if (result.kind !== "loaded") {
if (result.kind === "expired") {
markExpired(fileId);
}
setFailureState(result);
return;
}
setContent(result.content);
void onPreview?.({ content: result.content, fileName });
}}
/>
);
const framedButton = frameHref ? (
<AttachmentPreviewFrame
href={frameHref}
displayName={fileName ?? "Pasted text"}
downloadName={downloadName}
>
{button}
</AttachmentPreviewFrame>
) : (
button
);
return (
<div className="flex flex-col items-start gap-1">
{framedButton}
{showStatus && isLoading ? (
<span
role="status"
aria-live="polite"
className="text-xs text-content-secondary"
>
Loading attachment preview…
</span>
) : null}
</div>
);
};
const RemoteImageBlock: FC<{
fileId?: string;
href: string;
displayName: string;
onImageClick?: (src: string) => void;
}> = ({ fileId, href, displayName, onImageClick }) => {
const { hasExpired, markExpired } = useExpiredFileIds();
const isKnownExpired = fileId !== undefined && hasExpired(fileId);
const [failureState, setFailureState] = useState<AttachmentFailureState>(
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
);
const probeRequest = useLatestAbortController(isKnownExpired);
if (isKnownExpired) {
return (
<AttachmentFallbackTile
state={{ kind: "expired" }}
labels={imageAttachmentFailureLabels}
/>
);
}
if (failureState.kind !== "idle") {
return (
<AttachmentFallbackTile
state={failureState}
labels={imageAttachmentFailureLabels}
/>
);
}
return (
<button
type="button"
aria-label={`View ${displayName}`}
className="inline-block rounded-md border-0 bg-transparent p-0"
onClick={(event) => {
event.stopPropagation();
onImageClick?.(href);
}}
>
<ImageThumbnail
previewUrl={href}
name={displayName}
className="cursor-pointer transition-opacity hover:opacity-80"
onError={() => {
// Inline (data:) images can't be probed; the browser
// already failed to decode them, so fall straight to a
// generic failure tile.
if (fileId === undefined) {
setFailureState({ kind: "failed" });
return;
}
if (hasExpired(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(href, controller.signal)
.then((reason) => {
if (!probeRequest.clear(controller)) {
return;
}
if (reason.kind === "expired") {
markExpired(fileId);
}
setFailureState(reason);
})
.catch((error) => {
if (!probeRequest.clear(controller)) {
return;
}
if (isAbortError(error)) {
return;
}
setFailureState(attachmentFailureFromError(error));
});
}}
/>
</button>
);
};
const FileCard: FC<{
block: FileAttachmentBlock;
href: string;
}> = ({ block, href }) => {
const displayName = getAttachmentDisplayName(block);
const downloadName = getAttachmentDownloadName(block);
const badgeLabel = getAttachmentBadgeLabel(block);
return (
<a
href={href}
download={downloadName}
onClick={(event) => event.stopPropagation()}
aria-label={`Download ${displayName}`}
className="inline-flex h-16 max-w-sm items-center gap-3 rounded-md border border-solid border-border-default bg-surface-tertiary px-3 py-2 no-underline transition-colors hover:bg-surface-quaternary"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-surface-secondary">
{badgeLabel ? (
<span className="text-[10px] font-semibold tracking-wide text-content-secondary">
{badgeLabel}
</span>
) : (
<FileIcon
aria-hidden="true"
className="h-4 w-4 text-content-secondary"
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-content-primary">
{displayName}
</div>
<div className="text-xs text-content-secondary">Download file</div>
</div>
<DownloadIcon
aria-hidden="true"
className="h-4 w-4 shrink-0 text-content-secondary"
/>
</a>
);
};
export const AttachmentBlock: FC<{
block: FileAttachmentBlock;
onImageClick?: (src: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
framePreview?: boolean;
showTextStatus?: boolean;
}> = ({
block,
onImageClick,
onTextFileClick,
framePreview = false,
showTextStatus = false,
}) => {
const [revealedInlineText, setRevealedInlineText] = useState(false);
const href = getAttachmentHref(block);
const displayName = getAttachmentDisplayName(block);
const downloadName = getAttachmentDownloadName(block);
if (isTextPreviewAttachmentMediaType(block.media_type)) {
if (block.file_id) {
return (
<RemoteTextAttachmentButton
fileId={block.file_id}
fileName={displayName}
frameHref={framePreview ? href : undefined}
downloadName={downloadName}
onPreview={onTextFileClick}
showStatus={showTextStatus}
/>
);
}
if (block.data == null) {
return null;
}
const content = decodeInlineTextAttachment(block.data);
const button = (
<InlineTextAttachmentButton
content={revealedInlineText ? content : "Pasted text"}
fileName={displayName}
isPlaceholder={!revealedInlineText}
onPreview={() => {
setRevealedInlineText(true);
void onTextFileClick?.({ content, fileName: displayName });
}}
/>
);
return framePreview && href ? (
<AttachmentPreviewFrame
href={href}
displayName={displayName}
downloadName={downloadName}
>
{button}
</AttachmentPreviewFrame>
) : (
button
);
}
if (block.media_type.startsWith("image/")) {
if (!href) {
return null;
}
const image = (
<RemoteImageBlock
fileId={block.file_id ?? undefined}
href={href}
displayName={displayName}
onImageClick={onImageClick}
/>
);
return framePreview ? (
<AttachmentPreviewFrame
href={href}
displayName={displayName}
downloadName={downloadName}
>
{image}
</AttachmentPreviewFrame>
) : (
image
);
}
if (!href) {
return null;
}
return <FileCard block={block} href={href} />;
};
@@ -11,6 +11,7 @@ import {
} from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { getChatFileURL } from "../../utils/chatAttachments";
import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment";
import { ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
@@ -87,6 +88,10 @@ const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
body: "Quarterly revenue increased 18% year over year after the new pricing rollout stabilized customer expansion.",
},
],
[
"storybook-json-text",
{ status: 200, body: '{"status":"ok","items":[1,2,3]}' },
],
[
"storybook-text-only",
{
@@ -140,6 +145,7 @@ const ATTACHMENT_RESPONSES = new Map<string, AttachmentResponse>([
contentType: "application/json",
},
],
["storybook-text-error", { body: "Temporary failure", status: 503 }],
]);
let attachmentFetchCounts = new Map<string, number>();
@@ -395,7 +401,7 @@ export const UserMessageWithExpiredImage: Story = {
const expiredTile = await findAttachmentTile(canvas, "Image expired");
expect(canvas.getByText("This upload has expired")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View image" }),
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
// The tooltip explains the retention policy generically so the
@@ -433,10 +439,11 @@ export const UserMessageWithRepeatedExpiredImage: Story = {
);
expect(getAttachmentFetchCount("storybook-expired-image")).toBe(1);
expect(
canvas.queryByRole("button", { name: "View image" }),
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
},
};
/** File-id images that fail with a non-404 status render a generic failure tile. */
export const UserMessageWithFailedRemoteImage: Story = {
args: buildStoryArgs(
@@ -452,7 +459,7 @@ export const UserMessageWithFailedRemoteImage: Story = {
await findAttachmentTile(canvas, "Image failed to load");
expect(canvas.getByText("This image failed to load")).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View image" }),
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
// When the probe returns a structured error body, the tooltip
@@ -488,6 +495,7 @@ export const UserMessageWithUndisplayableRemoteImage: Story = {
);
},
};
/** Invalid inline image data skips the probe and renders the generic failure tile. */
export const UserMessageWithInvalidInlineImage: Story = {
args: buildStoryArgs(
@@ -505,7 +513,7 @@ export const UserMessageWithInvalidInlineImage: Story = {
canvas.getByText("Inline image data is corrupt"),
).toBeInTheDocument();
expect(
canvas.queryByRole("button", { name: "View image" }),
canvas.queryByRole("button", { name: "View Attached image" }),
).not.toBeInTheDocument();
},
};
@@ -531,6 +539,70 @@ export const UserMessageWithTextAttachment: Story = {
},
};
export const UserMessageWithJSONAttachment: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Here is the structured report." },
{
type: "file",
file_id: "storybook-json-text",
media_type: "application/json",
name: "report.json",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View report.json",
});
expect(textButton).toHaveTextContent("report.json");
await userEvent.click(textButton);
expect(await canvas.findByText(/"status":"ok"/i)).toBeInTheDocument();
},
};
export const UserMessageWithDownloadableFile: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "I attached the deployment report." },
{
type: "file",
media_type: "application/pdf",
file_id: "storybook-user-deployment-report",
name: "deployment-report.pdf",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const downloadLink = canvas.getByRole("link", {
name: "Download deployment-report.pdf",
});
expect(downloadLink).toHaveAttribute(
"href",
"/api/experimental/chats/files/storybook-user-deployment-report",
);
expect(canvas.getByText("deployment-report.pdf")).toBeInTheDocument();
},
};
export const UserMessageWithMultipleTextAttachments: Story = {
args: buildStoryArgs(
buildUserMessage({
@@ -630,6 +702,79 @@ export const UserMessageWithFailedTextAttachment: Story = {
},
};
export const UserMessageWithInlineTextAttachment: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Here is inline context:" },
{
type: "file",
media_type: "text/plain",
data: encodeInlineTextAttachment(
"Inline deployment note: verify the feature flag before rollout.",
),
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View text attachment",
});
expect(textButton).toHaveTextContent(/Pasted text/i);
await userEvent.click(textButton);
expect(
await canvas.findByText(/Inline deployment note/i),
).toBeInTheDocument();
},
};
/**
* Non-JSON error bodies (a bare `Temporary failure` text body with status 503)
* still surface the shared failure tile, and the raw body must not leak into
* the message stream where it would look like assistant content.
*/
export const UserMessageWithFailedTextAttachmentNonJSONBody: Story = {
args: {
...defaultArgs,
parsedMessages: parseMessagesWithMergedTools([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "The preview fetch will fail." },
{
type: "file",
file_id: "storybook-text-error",
media_type: "text/plain",
name: "preview.txt",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textButton = await canvas.findByRole("button", {
name: "View preview.txt",
});
await userEvent.click(textButton);
await findAttachmentTile(canvas, "Attachment failed to load");
expect(
canvas.queryByRole("button", { name: "View preview.txt" }),
).not.toBeInTheDocument();
expect(canvas.queryByText(/Temporary failure/i)).not.toBeInTheDocument();
},
};
/** Visual regression: text and image attachments render at the same height. */
export const UserMessageWithMixedAttachments: Story = {
args: buildStoryArgs(
@@ -681,12 +826,6 @@ export const AssistantMessageWithImage: Story = {
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "Generate an image" }],
},
{
...baseMessage,
id: 2,
role: "assistant",
content: [
{ type: "text", text: "Here is the generated image:" },
@@ -694,6 +833,7 @@ export const AssistantMessageWithImage: Story = {
type: "file",
media_type: "image/png",
data: TEST_PNG_B64,
name: "generated-image.png",
},
],
},
@@ -701,8 +841,55 @@ export const AssistantMessageWithImage: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const images = canvas.getAllByRole("img", { name: "Attached image" });
const images = canvas.getAllByRole("img", { name: "generated-image.png" });
expect(images).toHaveLength(1);
expect(images[0]).toHaveAttribute(
"src",
`data:image/png;base64,${TEST_PNG_B64}`,
);
expect(
canvas.queryByRole("link", { name: "Download generated-image.png" }),
).not.toBeInTheDocument();
const viewButton = canvas.getByRole("button", {
name: "View generated-image.png",
});
viewButton.focus();
expect(viewButton).toHaveFocus();
await waitFor(() => {
expect(
canvas.getByRole("link", { name: "Download generated-image.png" }),
).toBeVisible();
});
},
};
export const AssistantMessageWithUnnamedDownloadableFile: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "assistant",
content: [
{ type: "text", text: "I attached the file without a custom name." },
{
type: "file",
media_type: "application/pdf",
file_id: "storybook-unnamed-report",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const downloadLink = canvas.getByRole("link", {
name: "Download Attached file",
});
expect(downloadLink).toBeInTheDocument();
expect(downloadLink).toHaveAttribute("download", "attachment.pdf");
expect(canvas.getByText("Attached file")).toBeInTheDocument();
},
};
@@ -29,6 +29,10 @@ import { WebSearchSources } from "../ChatElements/tools";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import {
AttachmentBlock,
type PreviewTextAttachment,
} from "./AttachmentBlocks";
import { ExpiredFileIdsProvider } from "./ExpiredFileIdsContext";
import { deriveMessageDisplayState } from "./messageHelpers";
import { getEditableUserMessagePayload } from "./messageParsing";
@@ -39,7 +43,7 @@ import type {
ParsedMessageEntry,
RenderBlock,
} from "./types";
import { FileBlock, UserMessageContent } from "./UserMessageContent";
import { UserMessageContent } from "./UserMessageContent";
const getChatMessageTextContent = (
content: readonly TypesGen.ChatMessagePart[] | undefined,
@@ -135,7 +139,7 @@ export const BlockList: FC<{
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
mcpServers?: readonly TypesGen.MCPServerConfig[];
onImageClick?: (src: string) => void;
onTextFileClick?: (content: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
onImplementPlan?: () => Promise<void> | void;
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | void;
isChatCompleted?: boolean;
@@ -283,11 +287,13 @@ export const BlockList: FC<{
}
case "file":
return (
<FileBlock
<AttachmentBlock
key={`${keyPrefix}-file-${block.file_id ?? index}`}
block={block}
onImageClick={onImageClick}
onTextFileClick={onTextFileClick}
framePreview
showTextStatus
/>
);
case "sources":
@@ -388,7 +394,8 @@ const ChatMessageItem = memo<{
}) => {
const isUser = message.role === "user";
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewText, setPreviewText] = useState<string | null>(null);
const [previewText, setPreviewText] =
useState<PreviewTextAttachment | null>(null);
const displayState = deriveMessageDisplayState({
message,
parsed,
@@ -510,7 +517,8 @@ const ChatMessageItem = memo<{
)}
{previewText !== null && (
<TextPreviewDialog
content={previewText}
content={previewText.content}
fileName={previewText.fileName}
onClose={() => setPreviewText(null)}
/>
)}
@@ -1,327 +1,16 @@
import { AlertTriangleIcon, FileTextIcon } from "lucide-react";
import { type FC, Fragment, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { type FC, Fragment } from "react";
import { cn } from "#/utils/cn";
import { useLatestAbortController } from "../../hooks/useLatestAbortController";
import {
type AttachmentFailure,
attachmentFailureFromError,
getChatFileURL,
isAbortError,
probeAttachmentFailure,
} from "../../utils/chatAttachments";
import {
decodeInlineTextAttachment,
fetchTextAttachmentContent,
formatTextAttachmentPreview,
} from "../../utils/fetchTextAttachment";
import { ImageThumbnail } from "../AgentChatInput";
import { Message, MessageContent } from "../ChatElements";
import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode";
import { useExpiredFileIds } from "./ExpiredFileIdsContext";
import {
AttachmentBlock,
type PreviewTextAttachment,
} from "./AttachmentBlocks";
import type {
MessageDisplayState,
UserFileRenderBlock,
UserInlineRenderBlock,
} from "./messageHelpers";
type ChatImageSource =
| { kind: "file"; fileId: string; src: string }
| { kind: "inline"; src: string };
type AttachmentFailureState = { kind: "idle" } | AttachmentFailure;
type AttachmentFailureLabels = {
expired: string;
failed: string;
};
const attachmentRetentionTooltip =
"Chat attachments are deleted after the retention window set for this deployment.";
const imageAttachmentFailureLabels: AttachmentFailureLabels = {
expired: "Image expired",
failed: "Image failed to load",
};
const textAttachmentFailureLabels: AttachmentFailureLabels = {
expired: "Attachment expired",
failed: "Attachment failed to load",
};
const InlineTextAttachmentButton: FC<{
content: string;
onPreview?: (content: string) => void;
isPlaceholder?: boolean;
}> = ({ content, onPreview, isPlaceholder }) => {
return (
<button
type="button"
aria-label="View text attachment"
className="inline-flex h-16 max-w-sm items-center gap-2 rounded-md border-0 bg-surface-tertiary px-3 py-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={(event) => {
event.stopPropagation();
onPreview?.(content);
}}
>
<FileTextIcon className="size-icon-sm shrink-0 text-content-secondary" />
<span
className={cn(
"line-clamp-2 min-w-0 text-content-secondary",
isPlaceholder ? "text-sm" : "font-mono text-xs",
)}
>
{isPlaceholder ? content : formatTextAttachmentPreview(content)}
</span>
</button>
);
};
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 [failureState, setFailureState] = useState<AttachmentFailureState>(
() => (isKnownExpired ? { kind: "expired" } : { kind: "idle" }),
);
const request = useLatestAbortController(isKnownExpired);
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={() => {
if (content !== null) {
onPreview?.(content);
return;
}
const controller = request.start();
void fetchTextAttachmentContent(fileId, controller.signal)
.then((result) => {
if (!request.clear(controller)) {
return;
}
if (result.kind === "loaded") {
setContent(result.content);
onPreview?.(result.content);
return;
}
if (result.kind === "expired") {
markExpired(fileId);
}
setFailureState(result);
})
.catch((error) => {
if (!request.clear(controller)) {
return;
}
if (isAbortError(error)) {
return;
}
console.warn("Failed to load text attachment:", error);
setFailureState(attachmentFailureFromError(error));
});
}}
/>
);
};
const AttachmentFallbackTile: FC<{
state: AttachmentFailure;
labels: AttachmentFailureLabels;
className?: string;
}> = ({ state, labels, className = "h-16 w-16" }) => {
const label = state.kind === "expired" ? labels.expired : labels.failed;
const tile = (
<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;
onTextFileClick?: (content: string) => void;
}> = ({ block, onImageClick, onTextFileClick }) => {
if (block.media_type === "text/plain") {
if (block.file_id) {
return (
<TextAttachmentButton
fileId={block.file_id}
onPreview={onTextFileClick}
/>
);
}
if (block.data != null) {
return (
<InlineTextAttachmentButton
content={decodeInlineTextAttachment(block.data)}
onPreview={onTextFileClick}
/>
);
}
}
if (!block.media_type.startsWith("image/")) {
return null;
}
const source: ChatImageSource = block.file_id
? {
kind: "file",
fileId: block.file_id,
src: getChatFileURL(block.file_id),
}
: {
kind: "inline",
src: `data:${block.media_type};base64,${block.data ?? ""}`,
};
return <ChatImageBlock source={source} onImageClick={onImageClick} />;
};
const renderUserInlineBlock = (block: UserInlineRenderBlock, index: number) => {
if (block.type === "response") {
return <Fragment key={index}>{block.text}</Fragment>;
@@ -344,7 +33,7 @@ export const UserMessageContent: FC<{
isEditing?: boolean;
fadeFromBottom?: boolean;
onImageClick?: (src: string) => void;
onTextFileClick?: (content: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
}> = ({
displayState,
markdown,
@@ -388,11 +77,12 @@ export const UserMessageContent: FC<{
)}
>
{displayState.userFileBlocks.map((block, index) => (
<FileBlock
<AttachmentBlock
key={`user-file-${block.file_id ?? index}`}
block={block}
onImageClick={onImageClick}
onTextFileClick={onTextFileClick}
showTextStatus
/>
))}
</div>
@@ -5,7 +5,7 @@ export type UserInlineRenderBlock =
| Extract<RenderBlock, { type: "response" }>
| Extract<RenderBlock, { type: "file-reference" }>;
export type UserFileRenderBlock = Extract<RenderBlock, { type: "file" }>;
type UserFileRenderBlock = Extract<RenderBlock, { type: "file" }>;
export type MessageDisplayState = {
shouldHide: boolean;
@@ -3,6 +3,7 @@ import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated";
import { getSubagentDescriptor } from "../ChatElements/tools/subagentDescriptor";
import {
buildSubagentMaps,
getEditableUserMessagePayload,
mergeTools,
parseMessageContent,
parseMessagesWithMergedTools,
@@ -73,6 +74,86 @@ describe("parseToolResultIsError", () => {
});
});
describe("getEditableUserMessagePayload", () => {
it("keeps only editable stored attachments", () => {
const cases = [
{
message: {
id: 1,
chat_id: "chat-1",
created_at: "2026-04-21T00:00:00.000Z",
role: "user",
content: [
{ type: "text", text: "Please edit this draft." },
{ type: "file", media_type: "image/png", file_id: "image-file" },
{
type: "file",
media_type: "application/json",
file_id: "json-file",
name: "report.json",
},
{
type: "file",
media_type: "application/pdf",
file_id: "pdf-file",
name: "manual.pdf",
},
{
type: "file",
media_type: "application/zip",
file_id: "zip-file",
name: "archive.zip",
},
],
} satisfies ChatMessage,
want: {
text: "Please edit this draft.",
fileBlocks: [
{ type: "file", media_type: "image/png", file_id: "image-file" },
{
type: "file",
media_type: "application/json",
file_id: "json-file",
name: "report.json",
},
{
type: "file",
media_type: "application/pdf",
file_id: "pdf-file",
name: "manual.pdf",
},
],
},
},
{
message: {
id: 2,
chat_id: "chat-1",
created_at: "2026-04-21T00:00:00.000Z",
role: "user",
content: [
{ type: "text", text: "Share the archive instead." },
{
type: "file",
media_type: "application/zip",
file_id: "zip-file",
name: "archive.zip",
},
],
} satisfies ChatMessage,
want: {
text: "Share the archive instead.",
fileBlocks: undefined,
},
},
];
for (const { message, want } of cases) {
expect(getEditableUserMessagePayload(message)).toEqual(want);
}
});
});
describe("parseMessageContent", () => {
it("returns empty result for undefined content", () => {
const result = parseMessageContent(undefined);
@@ -237,11 +237,18 @@ export const parseMessageContent = (
return parsed;
};
const isEditableAttachmentMediaType = (mediaType: string): boolean =>
mediaType.startsWith("image/") ||
mediaType === "text/plain" ||
mediaType === "text/markdown" ||
mediaType === "text/csv" ||
mediaType === "application/json" ||
mediaType === "application/pdf";
const isEditableUserMessageFileBlock = (
block: RenderBlock,
): block is TypesGen.ChatFilePart =>
block.type === "file" &&
(block.media_type.startsWith("image/") || block.media_type === "text/plain");
block.type === "file" && isEditableAttachmentMediaType(block.media_type);
export const getEditableUserMessagePayload = (
message: TypesGen.ChatMessage,
@@ -1343,7 +1343,7 @@ export const ComputerTextFallback: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Text-only results are collapsed by default (no image).
const toggle = canvas.getByRole("button", { name: /Screenshot/ });
const toggle = canvas.getByRole("button", { name: "Screenshot" });
expect(toggle).toBeInTheDocument();
expect(canvas.queryByRole("img")).toBeNull();
@@ -1398,6 +1398,50 @@ export const ComputerArrayResult: Story = {
},
};
export const ComputerPromotedAttachmentArrayResult: Story = {
args: {
name: "computer",
status: "completed",
result: [
{
type: "image",
data: DESKTOP_SCREENSHOT_BASE64,
mime_type: "image/jpeg",
attachment_file_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
attachment_name: "screenshot-2026-04-21T00-00-00Z.png",
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByRole("button", { name: "Screenshot" });
expect(toggle).toBeInTheDocument();
expect(
canvas.queryByRole("img", { name: "Screenshot from computer tool" }),
).toBeNull();
await userEvent.click(toggle);
expect(
canvas.getByText("Attached screenshot-2026-04-21T00-00-00Z.png"),
).toBeInTheDocument();
},
};
export const AttachFileLabelFallsBackToPathBasename: Story = {
args: {
name: "attach_file",
status: "completed",
args: {
path: "docs/runbooks/incident.md",
},
result: {},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Attached incident.md")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// Tool failure display stories
// ---------------------------------------------------------------------------
@@ -707,26 +707,30 @@ const ComputerRenderer: FC<ToolRendererProps> = ({
result,
isError,
}) => {
// The result can be a single object with {data, text, mime_type}
// or an array of content blocks.
let imageData = "";
let mimeType = "image/png";
let text = "";
let attachmentFileId = "";
let attachmentName = "";
if (Array.isArray(result)) {
for (const block of result) {
const blockRec = asRecord(block);
if (blockRec) {
if (blockRec.type === "image" || asString(blockRec.data)) {
imageData = asString(blockRec.data);
mimeType = asString(blockRec.mime_type) || "image/png";
}
if (
blockRec.type === "text" ||
(!imageData && asString(blockRec.text))
) {
text = asString(blockRec.text);
}
if (!blockRec) {
continue;
}
if (blockRec.type === "image" || asString(blockRec.data)) {
imageData = asString(blockRec.data);
mimeType = asString(blockRec.mime_type) || "image/png";
}
if (blockRec.type === "text" || (!imageData && asString(blockRec.text))) {
text = asString(blockRec.text);
}
if (!attachmentFileId) {
attachmentFileId = asString(blockRec.attachment_file_id);
}
if (!attachmentName) {
attachmentName = asString(blockRec.attachment_name);
}
}
} else {
@@ -735,6 +739,17 @@ const ComputerRenderer: FC<ToolRendererProps> = ({
imageData = asString(rec.data);
mimeType = asString(rec.mime_type) || "image/png";
text = asString(rec.text);
attachmentFileId = asString(rec.attachment_file_id);
attachmentName = asString(rec.attachment_name);
}
}
if (attachmentFileId) {
imageData = "";
if (!text) {
text = attachmentName
? `Attached ${attachmentName}`
: "Attached screenshot";
}
}
@@ -219,6 +219,18 @@ export const ToolLabel: React.FC<{
Summarized
</span>
);
case "attach_file": {
const attachedName =
(parsedResult ? asString(parsedResult.name) : "") ||
(parsed ? asString(parsed.name) : "") ||
(parsed ? asString(parsed.path).split("/").pop() : "") ||
"file";
return (
<span className="truncate text-sm text-content-secondary">
{`Attached ${attachedName}`}
</span>
);
}
case "computer":
return (
<span className="truncate text-sm text-content-secondary">
@@ -7,7 +7,7 @@ export type AttachmentFailure =
| { kind: "failed"; detail?: string };
export const getChatFileURL = (fileId: string) =>
`/api/experimental/chats/files/${fileId}`;
`/api/experimental/chats/files/${encodeURIComponent(fileId)}`;
export const isAbortError = (error: unknown): error is Error =>
error instanceof Error && error.name === "AbortError";
@@ -1,13 +1,11 @@
import {
decodeInlineTextAttachment,
encodeInlineTextAttachment,
fetchTextAttachmentContent,
formatTextAttachmentPreview,
getTextAttachmentErrorMessage,
} from "./fetchTextAttachment";
const encodeUtf8Base64 = (value: string) => {
const bytes = new TextEncoder().encode(value);
return btoa(String.fromCharCode(...bytes));
};
describe("formatTextAttachmentPreview", () => {
it('returns "Pasted text" for empty content', () => {
expect(formatTextAttachmentPreview("")).toBe("Pasted text");
@@ -41,7 +39,9 @@ describe("decodeInlineTextAttachment", () => {
it("decodes base64-encoded UTF-8 text", () => {
const text = "Hello 👋 café";
expect(decodeInlineTextAttachment(encodeUtf8Base64(text))).toBe(text);
expect(decodeInlineTextAttachment(encodeInlineTextAttachment(text))).toBe(
text,
);
});
it("falls back to the raw string when base64 decoding fails", () => {
@@ -52,3 +52,70 @@ describe("decodeInlineTextAttachment", () => {
expect(warn).toHaveBeenCalled();
});
});
describe("fetchTextAttachmentContent", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns a loaded result when the fetch succeeds", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("hello from the server", { status: 200 }),
);
const fileId = "folder/file-1?preview=yes";
await expect(fetchTextAttachmentContent(fileId)).resolves.toEqual({
kind: "loaded",
content: "hello from the server",
});
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/experimental/chats/files/folder%2Ffile-1%3Fpreview%3Dyes",
expect.anything(),
);
});
it("returns the API message for unauthorized attachment fetches", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ message: "Sign in again to view files." }),
{
status: 401,
statusText: "Unauthorized",
headers: { "Content-Type": "application/json" },
},
),
);
await expect(fetchTextAttachmentContent("file-2")).resolves.toEqual({
kind: "failed",
detail: "Sign in again to view files.",
});
});
it("returns a classified failure when the fetch responds non-OK", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("nope", { status: 503 }),
);
const result = await fetchTextAttachmentContent("file-3");
expect(result.kind).not.toBe("loaded");
});
});
describe("getTextAttachmentErrorMessage", () => {
it("suppresses DOMException abort errors", () => {
expect(
getTextAttachmentErrorMessage(new DOMException("aborted", "AbortError")),
).toBeNull();
});
it("suppresses structural abort errors", () => {
expect(getTextAttachmentErrorMessage({ name: "AbortError" })).toBeNull();
});
it("falls back to the retry message for other failures", () => {
expect(getTextAttachmentErrorMessage(new Error("boom"))).toBe(
"Couldn't load preview. Select again to retry.",
);
});
});
@@ -39,6 +39,27 @@ export function decodeInlineTextAttachment(content: string): string {
}
}
/**
* Encodes UTF-8 text as base64. Inverse of decodeInlineTextAttachment.
*/
export function encodeInlineTextAttachment(text: string): string {
const bytes = new TextEncoder().encode(text);
return btoa(String.fromCharCode(...bytes));
}
export function getTextAttachmentErrorMessage(error: unknown): string | null {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError"
) {
return null;
}
return "Couldn't load preview. Select again to retry.";
}
/**
* Fetches the text content of a chat file attachment by its ID.
*/