mirror of
https://github.com/coder/coder.git
synced 2026-06-04 21:48:22 +00:00
cc4e04afde
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
112 lines
2.9 KiB
Go
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
|
|
}
|