mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
268 lines
9.2 KiB
Go
268 lines
9.2 KiB
Go
package chatd //nolint:testpackage
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/mock/gomock"
|
|
|
|
"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"
|
|
)
|
|
|
|
func TestStoreChatAttachment_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
tx := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
orgID := uuid.New()
|
|
fileID := uuid.New()
|
|
chatSnapshot := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
|
|
expectStoreChatAttachmentTx(t, db, tx)
|
|
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
|
|
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).DoAndReturn(
|
|
func(_ context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
|
|
require.Equal(t, ownerID, arg.OwnerID)
|
|
require.Equal(t, orgID, arg.OrganizationID)
|
|
require.Equal(t, "build.log", arg.Name)
|
|
require.Equal(t, "text/plain", arg.Mimetype)
|
|
require.Equal(t, []byte("build output"), arg.Data)
|
|
return database.InsertChatFileRow{ID: fileID}, nil
|
|
},
|
|
)
|
|
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
|
|
ChatID: chatID,
|
|
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
|
|
FileIds: []uuid.UUID{fileID},
|
|
}).Return(int32(0), nil)
|
|
|
|
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, chattool.AttachmentMetadata{
|
|
FileID: fileID,
|
|
MediaType: "text/plain",
|
|
Name: "build.log",
|
|
}, attachment)
|
|
}
|
|
|
|
func TestStoreChatAttachment_UsesDetectNameForClassification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
tx := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
orgID := uuid.New()
|
|
fileID := uuid.New()
|
|
chatSnapshot := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
|
|
expectStoreChatAttachmentTx(t, db, tx)
|
|
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
|
|
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).DoAndReturn(
|
|
func(_ context.Context, arg database.InsertChatFileParams) (database.InsertChatFileRow, error) {
|
|
require.Equal(t, "payload.txt", arg.Name)
|
|
require.Equal(t, "application/json", arg.Mimetype)
|
|
return database.InsertChatFileRow{ID: fileID}, nil
|
|
},
|
|
)
|
|
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
|
|
ChatID: chatID,
|
|
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
|
|
FileIds: []uuid.UUID{fileID},
|
|
}).Return(int32(0), nil)
|
|
|
|
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "payload.txt", "report.json", []byte(`{"ok":true}`))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "payload.txt", attachment.Name)
|
|
require.Equal(t, "application/json", attachment.MediaType)
|
|
}
|
|
|
|
func TestStoreChatAttachment_RejectsUnsupportedStoredFileTypeBeforeDBWork(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
chatSnapshot := database.Chat{
|
|
ID: uuid.New(),
|
|
OwnerID: uuid.New(),
|
|
WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
|
}
|
|
|
|
attachment, err := server.storeChatAttachment(
|
|
context.Background(),
|
|
chatSnapshot,
|
|
"evil.svg",
|
|
"evil.svg",
|
|
[]byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`),
|
|
)
|
|
require.ErrorIs(t, err, chatfiles.ErrUnsupportedStoredFileType)
|
|
require.ErrorContains(t, err, "image/svg+xml")
|
|
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
|
|
}
|
|
|
|
func TestStoreChatAttachment_NoWorkspace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
attachment, err := server.storeChatAttachment(context.Background(), database.Chat{}, "build.log", "build.log", []byte("build output"))
|
|
require.ErrorContains(t, err, "no workspace is associated")
|
|
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
|
|
}
|
|
|
|
func TestStoreChatAttachment_WorkspaceLookupError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
tx := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
workspaceID := uuid.New()
|
|
chatSnapshot := database.Chat{
|
|
ID: uuid.New(),
|
|
OwnerID: uuid.New(),
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
|
|
expectStoreChatAttachmentTx(t, db, tx)
|
|
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{}, context.DeadlineExceeded)
|
|
|
|
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
|
|
require.ErrorContains(t, err, "resolve workspace")
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
|
|
}
|
|
|
|
func TestStoreChatAttachment_InsertError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
tx := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
workspaceID := uuid.New()
|
|
chatSnapshot := database.Chat{
|
|
ID: uuid.New(),
|
|
OwnerID: uuid.New(),
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
|
|
expectStoreChatAttachmentTx(t, db, tx)
|
|
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: uuid.New()}, nil)
|
|
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.Any()).Return(database.InsertChatFileRow{}, context.DeadlineExceeded)
|
|
|
|
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
|
|
require.ErrorContains(t, err, "insert chat file")
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
|
|
}
|
|
|
|
func TestStoreChatAttachment_StrictCapError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
tx := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
orgID := uuid.New()
|
|
fileID := uuid.New()
|
|
chatSnapshot := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
|
|
expectStoreChatAttachmentTx(t, db, tx)
|
|
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
|
|
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatFileParams{})).Return(database.InsertChatFileRow{ID: fileID}, nil)
|
|
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
|
|
ChatID: chatID,
|
|
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
|
|
FileIds: []uuid.UUID{fileID},
|
|
}).Return(int32(1), nil)
|
|
|
|
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
|
|
require.ErrorContains(t, err, "chat already has the maximum of 20 linked files")
|
|
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
|
|
}
|
|
|
|
func TestStoreChatAttachment_LinkError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
tx := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db}
|
|
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
orgID := uuid.New()
|
|
fileID := uuid.New()
|
|
chatSnapshot := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
|
|
expectStoreChatAttachmentTx(t, db, tx)
|
|
tx.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(database.Workspace{ID: workspaceID, OrganizationID: orgID}, nil)
|
|
tx.EXPECT().InsertChatFile(gomock.Any(), gomock.Any()).Return(database.InsertChatFileRow{ID: fileID}, nil)
|
|
tx.EXPECT().LinkChatFiles(gomock.Any(), database.LinkChatFilesParams{
|
|
ChatID: chatID,
|
|
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
|
|
FileIds: []uuid.UUID{fileID},
|
|
}).Return(int32(0), context.DeadlineExceeded)
|
|
|
|
attachment, err := server.storeChatAttachment(context.Background(), chatSnapshot, "build.log", "build.log", []byte("build output"))
|
|
require.ErrorContains(t, err, "link chat file")
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
require.Equal(t, chattool.AttachmentMetadata{}, attachment)
|
|
}
|
|
|
|
func expectStoreChatAttachmentTx(t *testing.T, db, tx *dbmock.MockStore) {
|
|
t.Helper()
|
|
|
|
db.EXPECT().InTx(gomock.Any(), gomock.AssignableToTypeOf(&database.TxOptions{})).DoAndReturn(
|
|
func(fn func(database.Store) error, opts *database.TxOptions) error {
|
|
require.NotNil(t, opts)
|
|
require.Equal(t, "store_chat_attachment", opts.TxIdentifier)
|
|
return fn(tx)
|
|
},
|
|
)
|
|
}
|