Files
coder/coderd/x/chatd/store_chat_attachment.go
T
Ethan cc4e04afde 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
2026-04-22 20:11:53 +10:00

112 lines
2.9 KiB
Go

package chatd
import (
"context"
"github.com/google/uuid"
"golang.org/x/xerrors"
"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"
)
func (p *Server) newStoreChatAttachmentFunc(workspaceCtx *turnWorkspaceContext) chattool.StoreFileFunc {
return func(
ctx context.Context,
name string,
detectName string,
data []byte,
) (chattool.AttachmentMetadata, error) {
workspaceCtx.chatStateMu.Lock()
chatSnapshot := *workspaceCtx.currentChat
workspaceCtx.chatStateMu.Unlock()
return p.storeChatAttachment(ctx, chatSnapshot, name, detectName, data)
}
}
func (p *Server) storeChatAttachment(
ctx context.Context,
chatSnapshot database.Chat,
name string,
detectName string,
data []byte,
) (chattool.AttachmentMetadata, error) {
if !chatSnapshot.WorkspaceID.Valid {
return chattool.AttachmentMetadata{}, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one")
}
storedName, mediaType, err := chatfiles.PrepareStoredFile(name, detectName, data)
if err != nil {
return chattool.AttachmentMetadata{}, err
}
// Insert and link in one transaction so a cap rejection or linking
// failure does not leave behind an unlinked chat file row.
var attachment chattool.AttachmentMetadata
err = p.db.InTx(func(tx database.Store) error {
ws, err := tx.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID)
if err != nil {
return xerrors.Errorf("resolve workspace: %w", err)
}
attachment, err = storeLinkedChatFileTx(
ctx,
tx,
chatSnapshot.ID,
chatSnapshot.OwnerID,
ws.OrganizationID,
storedName,
mediaType,
data,
)
return err
}, database.DefaultTXOptions().WithID("store_chat_attachment"))
if err != nil {
return chattool.AttachmentMetadata{}, err
}
return attachment, nil
}
func storeLinkedChatFileTx(
ctx context.Context,
tx database.Store,
chatID uuid.UUID,
ownerID uuid.UUID,
organizationID uuid.UUID,
name string,
mediaType string,
data []byte,
) (chattool.AttachmentMetadata, error) {
row, err := tx.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: ownerID,
OrganizationID: organizationID,
Name: name,
Mimetype: mediaType,
Data: data,
})
if err != nil {
return chattool.AttachmentMetadata{}, xerrors.Errorf("insert chat file: %w", err)
}
rejected, err := tx.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{row.ID},
})
if err != nil {
return chattool.AttachmentMetadata{}, xerrors.Errorf("link chat file: %w", err)
}
if rejected > 0 {
return chattool.AttachmentMetadata{}, xerrors.Errorf("chat already has the maximum of %d linked files", codersdk.MaxChatFileIDs)
}
return chattool.AttachmentMetadata{
FileID: row.ID,
MediaType: mediaType,
Name: name,
}, nil
}