mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
+198
-11
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user