Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/components/Chat/ChatAssistantMessage.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ChatAssistantMessage } from './ChatAssistantMessage'
import { FileCard } from './FileCard'

const meta = {
title: 'Components/Chat/ChatAssistantMessage',
Expand Down Expand Up @@ -40,3 +41,13 @@ export const MultipleMessages: Story = {
</div>
),
}

export const WithFileAttachment: Story = {
args: { message: '' },
render: () => (
<div className="flex flex-col gap-2 w-full max-w-2xl">
<ChatAssistantMessage message="Here's the report you requested:" />
<FileCard fileName="quarterly-report.pdf" fileSize={1024 * 250} className="w-fit" />
</div>
),
}
53 changes: 13 additions & 40 deletions src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import * as React from 'react'
import { useState } from 'react'
import { X, FileText, FileImage, FileVideo, File } from 'lucide-react'
import { mergeClasses } from '../../utils/classNames'
import { ButtonWidget } from '../Button/ButtonWidget'
import { ParagraphField } from '../ParagraphField/ParagraphField'
import { FileCard } from './FileCard'

export interface ChatInputFile {
name: string
size?: number
type?: string
}

Expand All @@ -27,23 +28,6 @@ export interface ChatInputProps {
className?: string
}

const getFileIcon = (type?: string) => {
if (!type) return File
if (type.startsWith('image/')) return FileImage
if (type.startsWith('video/')) return FileVideo
if (type === 'application/pdf' || type.startsWith('text/')) return FileText
return File
}

const getFileIconColor = (type?: string) => {
if (!type) return 'text-gray-500'
if (type.startsWith('image/')) return 'text-blue-500'
if (type.startsWith('video/')) return 'text-red-500'
if (type === 'application/pdf') return 'text-red-700'
if (type.startsWith('text/')) return 'text-blue-700'
return 'text-gray-500'
}

export const ChatInput: React.FC<ChatInputProps> = ({
placeholder = 'Type a message...',
onSubmit,
Expand Down Expand Up @@ -83,7 +67,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
input.onchange = (e) => {
const selected = (e.target as HTMLInputElement).files
if (selected) {
const newFiles = Array.from(selected).map(f => ({ name: f.name, type: f.type }))
const newFiles = Array.from(selected).map(f => ({ name: f.name, size: f.size, type: f.type }))
setFiles(prev => [...prev, ...newFiles])
}
}
Expand All @@ -96,27 +80,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({
<div className={mergeClasses(sailClasses, className)}>
<div className="flex flex-col flex-1">
{files.length > 0 && (
<div className="flex flex-col gap-1 px-3 pt-3">
{files.map((file, index) => {
const IconComponent = getFileIcon(file.type)
const iconColor = getFileIconColor(file.type)
return (
<div
key={index}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-sm border border-gray-200 bg-white w-fit max-w-full"
>
<IconComponent size={16} className={`shrink-0 ${iconColor}`} />
<span className="text-sm text-gray-900 truncate max-w-[200px]">{file.name}</span>
<button
onClick={() => setFiles(prev => prev.filter((_, i) => i !== index))}
aria-label={`Remove ${file.name}`}
className="shrink-0 p-0.5 rounded-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<X size={14} />
</button>
</div>
)
})}
<div className="flex flex-wrap gap-2 px-3 pt-3">
{files.map((file, index) => (
<FileCard
key={index}
fileName={file.name}
fileSize={file.size || 0}
showRemove
onRemove={() => setFiles(prev => prev.filter((_, i) => i !== index))}
/>
))}
</div>
)}
<ParagraphField
Expand Down
28 changes: 28 additions & 0 deletions src/components/Chat/ChatPanel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ChatPanel } from './ChatPanel'
import { ChatInput } from './ChatInput'
import { ChatUserMessage } from './ChatUserMessage'
import { ChatAssistantMessage } from './ChatAssistantMessage'
import { FileCard } from './FileCard'

const meta = {
title: 'Components/Chat/ChatPanel',
Expand Down Expand Up @@ -118,3 +119,30 @@ export const Interactive: Story = {
)
},
}

export const WithFileAttachments: Story = {
args: {
title: 'Design Review',
children: (
<div className="space-y-4">
<div className="flex flex-col gap-2 items-end">
<div className="flex flex-wrap gap-2 justify-end">
<FileCard fileName="homepage-mockup.png" fileSize={1024 * 1024 * 2.1} />
<FileCard fileName="dashboard-mockup.png" fileSize={1024 * 1024 * 1.8} />
<FileCard fileName="settings-mockup.png" fileSize={1024 * 512} />
</div>
<ChatUserMessage message="Here are the mockups for the new pages. Can you generate the React components?" />
</div>
<div className="flex flex-col gap-2">
<ChatAssistantMessage message="I've reviewed your mockups and generated the components. Here are the files:" />
<div className="flex flex-wrap gap-2">
<FileCard fileName="HomePage.tsx" fileSize={1024 * 12} className="w-fit" />
<FileCard fileName="Dashboard.tsx" fileSize={1024 * 18} className="w-fit" />
<FileCard fileName="Settings.tsx" fileSize={1024 * 8} className="w-fit" />
</div>
</div>
</div>
),
footer: <ChatInput showUpload />,
},
}
11 changes: 11 additions & 0 deletions src/components/Chat/ChatUserMessage.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ChatUserMessage } from './ChatUserMessage'
import { FileCard } from './FileCard'

const meta = {
title: 'Components/Chat/ChatUserMessage',
Expand Down Expand Up @@ -40,3 +41,13 @@ export const MultipleMessages: Story = {
</div>
),
}

export const WithFileAttachment: Story = {
args: { message: '' },
render: () => (
<div className="flex flex-col gap-2 w-full max-w-2xl items-end">
<FileCard fileName="quarterly-report.pdf" fileSize={1024 * 250} />
<ChatUserMessage message="Can you summarize this report for me?" />
</div>
),
}
82 changes: 82 additions & 0 deletions src/components/Chat/FileCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { fn, userEvent, within, expect } from 'storybook/test'
import { FileCard } from './FileCard'

const meta = {
title: 'Components/Chat/FileCard',
component: FileCard,
tags: ['autodocs'],
parameters: { layout: 'centered' },
} satisfies Meta<typeof FileCard>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
args: { fileName: 'document.pdf', fileSize: 1024 * 250 },
}

export const WithRemoveButton: Story = {
args: { fileName: 'presentation.pdf', fileSize: 1024 * 1024 * 2.5, showRemove: true },
}

export const ImageFile: Story = {
args: { fileName: 'screenshot.png', fileSize: 1024 * 512 },
}

export const CodeFile: Story = {
args: { fileName: 'component.tsx', fileSize: 1024 * 45 },
}

export const GenericFile: Story = {
args: { fileName: 'data.csv', fileSize: 1024 * 1024 * 1.8 },
}

export const LongFileName: Story = {
args: {
fileName: 'this-is-a-very-long-filename-that-should-be-truncated-in-the-display.pdf',
fileSize: 1024 * 500,
showRemove: true,
maxWidth: '320px',
},
}

export const LongFileNameReadOnly: Story = {
args: {
fileName: 'another-extremely-long-filename-for-testing-truncation-behavior.tsx',
fileSize: 1024 * 45,
showRemove: false,
maxWidth: '280px',
},
}

export const MultipleFilesInMessage: Story = {
render: () => (
<div className="space-y-2 w-80">
<FileCard fileName="requirements.pdf" fileSize={1024 * 250} />
<FileCard fileName="design-mockup.png" fileSize={1024 * 1024 * 2.1} />
<FileCard fileName="implementation.tsx" fileSize={1024 * 45} />
</div>
),
}

export const MultipleFilesInInput: Story = {
render: () => (
<div className="flex flex-wrap gap-2 max-w-md">
<FileCard fileName="document.pdf" fileSize={1024 * 250} showRemove onRemove={fn()} />
<FileCard fileName="image.jpg" fileSize={1024 * 512} showRemove onRemove={fn()} />
<FileCard fileName="code.tsx" fileSize={1024 * 45} showRemove onRemove={fn()} />
</div>
),
}

export const RemoveInteraction: Story = {
args: { fileName: 'test-file.pdf', fileSize: 1024 * 100, showRemove: true, onRemove: fn() },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
const removeButton = canvas.getByRole('button', { name: /remove file/i })

Check failure on line 77 in src/components/Chat/FileCard.stories.tsx

View workflow job for this annotation

GitHub Actions / test

[storybook (chromium)] src/components/Chat/FileCard.stories.tsx > Remove Interaction

TestingLibraryElementError: Click to debug the error directly in Storybook: http://localhost:6006/?path=/story/components-chat-filecard--remove-interaction&addonPanel=storybook/interactions/panel Unable to find an accessible element with the role "button" and name `/remove file/i` Here are the accessible roles: button: Name "Remove test-file.pdf": <button aria-label="Remove test-file.pdf" class="shrink-0 p-1 rounded-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors" /> -------------------------------------------------- Ignored nodes: comments, script, style <div> <div class="flex items-center gap-2 rounded-sm border border-gray-300 bg-white py-2 pl-3 pr-2" style="max-width: 320px;" > <svg aria-hidden="true" class="lucide lucide-file shrink-0 text-red-500" fill="none" height="20" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="20" xmlns="http://www.w3.org/2000/svg" > <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> <path d="M14 2v4a2 2 0 0 0 2 2h4" /> </svg> <div class="flex-1 min-w-0" > <div class="truncate text-sm font-semibold text-gray-900" > test-file.pdf </div> <div class="text-xs text-gray-700" > PDF • 100.0 KB </div> </div> <button aria-label="Remove test-file.pdf" class="shrink-0 p-1 rounded-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors" > <svg aria-hidden="true" class="lucide lucide-x" fill="none" height="16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="16" xmlns="http://www.w3.org/2000/svg" > <path d="M18 6 6 18" /> <path d="m6 6 12 12" /> </svg> </button> </div> </div> ❯ getByRole src/components/Chat/FileCard.stories.tsx:77:32 ❯ runStory node_modules/.pnpm/storybook@10.3.6_@testing-library+dom@10.4.1_react-dom@19.2.0_react@19.2.0__react@19.2.0/node_modules/storybook/dist/_browser-chunks/chunk-SZQXB3JV.js:1325:12 ❯ node_modules/.cache/storybook/10.3.6/1b3411d524db2f2d35165fc6094964fd983f5d0d0d28f17ab3feefa0cc53ef6f/sb-vitest/deps/@storybook_addon-vitest_internal_test-utils.js?v=42980e8d:131:184
await expect(removeButton).toBeInTheDocument()
await userEvent.click(removeButton)
await expect(args.onRemove).toHaveBeenCalled()
},
}
77 changes: 77 additions & 0 deletions src/components/Chat/FileCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as React from 'react'
import { X, File, FileImage, FileCode, FileText } from 'lucide-react'
import { mergeClasses } from '../../utils/classNames'

export interface FileCardProps {
/** The file name to display */
fileName: string
/** The file size in bytes */
fileSize: number
/** Whether to show the remove button */
showRemove?: boolean
/** Callback when the remove button is clicked */
onRemove?: () => void
/** Optional file type override (inferred from fileName if not provided) */
fileType?: string
/** Maximum width of the component (CSS value) */
maxWidth?: string
/** Additional Tailwind classes */
className?: string
}

function getFileExtension(filename: string): string {
const ext = filename.split('.').pop()
return ext ? ext.toUpperCase() : 'FILE'
}

function getFileIcon(filename: string) {
const ext = filename.split('.').pop()?.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || ''))
return <FileImage size={20} className="shrink-0 text-blue-500" />
if (ext === 'pdf')
return <File size={20} className="shrink-0 text-red-500" />
if (['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'html', 'css', 'json', 'xml'].includes(ext || ''))
return <FileCode size={20} className="shrink-0 text-purple-500" />
return <FileText size={20} className="shrink-0 text-gray-700" />
}

function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

export const FileCard: React.FC<FileCardProps> = ({
fileName,
fileSize,
showRemove = false,
onRemove,
fileType,
maxWidth = '320px',
className,
}) => {
const extension = fileType || getFileExtension(fileName)
const icon = getFileIcon(fileName)
const formattedSize = formatFileSize(fileSize)

const baseClasses = `flex items-center gap-2 rounded-sm border border-gray-300 bg-white py-2 ${showRemove ? 'pl-3 pr-2' : 'pl-3 pr-4'}`

return (
<div className={mergeClasses(baseClasses, className)} style={{ maxWidth }}>
{icon}
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-semibold text-gray-900">{fileName}</div>
<div className="text-xs text-gray-700">{extension} • {formattedSize}</div>
</div>
{showRemove && (
<button
onClick={onRemove}
aria-label={`Remove ${fileName}`}
className="shrink-0 p-1 rounded-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
<X size={16} />
</button>
)}
</div>
)
}
3 changes: 3 additions & 0 deletions src/components/Chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export { ChatInput } from './ChatInput'
export type { ChatInputProps, ChatInputFile } from './ChatInput'

export { FileCard } from './FileCard'
export type { FileCardProps } from './FileCard'

export { ChatPanel } from './ChatPanel'
export type { ChatPanelProps, ChatPanelHeaderAction } from './ChatPanel'

Expand Down
Loading