Skip to content

Commit 1de25af

Browse files
waleedlatif1claude
andcommitted
feat(mothership): file attachment indicators, persistence, and chat input improvements
- Show image thumbnails and file-icon cards above user messages in mothership chat - Persist file attachment metadata (key, filename, media_type, size) in DB with user messages - Restore attachments from history via /api/files/serve/ URLs so they survive refresh/navigation - Unify all chat file inputs to use shared CHAT_ACCEPT_ATTRIBUTE constant - Fix file thumbnail overflow: use flex-wrap instead of hidden horizontal scroll - Compact attachment cards in floating workflow chat messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5d308b3 commit 1de25af

File tree

14 files changed

+172
-67
lines changed

14 files changed

+172
-67
lines changed

apps/sim/app/api/mothership/chat/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ export async function POST(req: NextRequest) {
121121
role: 'user' as const,
122122
content: message,
123123
timestamp: new Date().toISOString(),
124+
...(fileAttachments &&
125+
fileAttachments.length > 0 && {
126+
fileAttachments: fileAttachments.map((f) => ({
127+
id: f.id,
128+
key: f.key,
129+
filename: f.filename,
130+
media_type: f.media_type,
131+
size: f.size,
132+
})),
133+
}),
124134
}
125135

126136
const [updated] = await db

apps/sim/app/chat/components/input/input.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react'
55
import { motion } from 'framer-motion'
66
import { Paperclip, Send, Square, X } from 'lucide-react'
77
import { Badge, Tooltip } from '@/components/emcn'
8+
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
89
import { VoiceInput } from '@/app/chat/components/input/voice-input'
910

1011
const logger = createLogger('ChatInput')
@@ -268,7 +269,7 @@ export const ChatInput: React.FC<{
268269
>
269270
{/* File Previews */}
270271
{attachedFiles.length > 0 && (
271-
<div className='mb-2 flex flex-wrap gap-2 px-3 pt-3 md:px-4'>
272+
<div className='mb-2 flex max-h-[160px] flex-wrap gap-2 overflow-y-auto px-3 pt-3 md:px-4'>
272273
{attachedFiles.map((file) => {
273274
const formatFileSize = (bytes: number) => {
274275
if (bytes === 0) return '0 B'
@@ -348,7 +349,7 @@ export const ChatInput: React.FC<{
348349
ref={fileInputRef}
349350
type='file'
350351
multiple
351-
accept='.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf,image/*'
352+
accept={CHAT_ACCEPT_ATTRIBUTE}
352353
onChange={(e) => {
353354
handleFileSelect(e.target.files)
354355
if (fileInputRef.current) {

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
44
import { ArrowUp, FileText, Loader2, Mic, Paperclip, X } from 'lucide-react'
55
import { Button } from '@/components/emcn'
66
import { cn } from '@/lib/core/utils/cn'
7+
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
78
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
89
import { useAnimatedPlaceholder } from '../../hooks'
910

@@ -38,9 +39,6 @@ export interface FileAttachmentForApi {
3839
size: number
3940
}
4041

41-
const ACCEPTED_FILE_TYPES =
42-
'image/*,.pdf,.txt,.csv,.md,.html,.json,.xml,text/plain,text/csv,text/markdown,text/html,application/json,application/xml,application/pdf'
43-
4442
interface UserInputProps {
4543
defaultValue?: string
4644
onSubmit: (text: string, fileAttachments?: FileAttachmentForApi[]) => void
@@ -82,14 +80,22 @@ export function UserInput({
8280
}, [])
8381

8482
const textareaRef = useRef<HTMLTextAreaElement>(null)
83+
const wasSendingRef = useRef(false)
84+
85+
useEffect(() => {
86+
if (wasSendingRef.current && !isSending) {
87+
textareaRef.current?.focus()
88+
}
89+
wasSendingRef.current = isSending
90+
}, [isSending])
8591

8692
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
8793
if ((e.target as HTMLElement).closest('button')) return
8894
textareaRef.current?.focus()
8995
}, [])
9096

9197
const handleSubmit = useCallback(() => {
92-
const fileAttachmentsForApi = files.attachedFiles
98+
const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles
9399
.filter((f) => !f.uploading && f.key)
94100
.map((f) => ({
95101
id: f.id,
@@ -194,7 +200,7 @@ export function UserInput({
194200
>
195201
{/* Attached files */}
196202
{files.attachedFiles.length > 0 && (
197-
<div className='mb-[6px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
203+
<div className='mb-[6px] flex flex-wrap gap-[6px]'>
198204
{files.attachedFiles.map((file) => {
199205
const isImage = file.type.startsWith('image/')
200206
return (
@@ -319,7 +325,7 @@ export function UserInput({
319325
type='file'
320326
onChange={files.handleFileChange}
321327
className='hidden'
322-
accept={ACCEPTED_FILE_TYPES}
328+
accept={CHAT_ACCEPT_ATTRIBUTE}
323329
multiple
324330
/>
325331
</div>

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useCallback, useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { FileText } from 'lucide-react'
56
import { useParams, useRouter } from 'next/navigation'
67
import { useSession } from '@/lib/auth/auth-client'
78
import {
@@ -178,8 +179,38 @@ export function Home({ chatId }: HomeProps = {}) {
178179
<div className='mx-auto max-w-[42rem] space-y-6'>
179180
{messages.map((msg, index) => {
180181
if (msg.role === 'user') {
182+
const hasAttachments = msg.attachments && msg.attachments.length > 0
181183
return (
182-
<div key={msg.id} className='flex justify-end pt-3'>
184+
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
185+
{hasAttachments && (
186+
<div className='flex max-w-[70%] flex-wrap justify-end gap-[6px]'>
187+
{msg.attachments!.map((att) => {
188+
const isImage = att.media_type.startsWith('image/')
189+
return isImage && att.previewUrl ? (
190+
<div
191+
key={att.id}
192+
className='h-[56px] w-[56px] overflow-hidden rounded-[8px]'
193+
>
194+
<img
195+
src={att.previewUrl}
196+
alt={att.filename}
197+
className='h-full w-full object-cover'
198+
/>
199+
</div>
200+
) : (
201+
<div
202+
key={att.id}
203+
className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'
204+
>
205+
<FileText className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
206+
<span className='truncate text-[11px] text-[var(--text-secondary)]'>
207+
{att.filename}
208+
</span>
209+
</div>
210+
)
211+
})}
212+
</div>
213+
)}
183214
<div className='max-w-[70%] rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
184215
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
185216
{msg.content}

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ import { tableKeys } from '@/hooks/queries/tables'
77
import {
88
type TaskChatHistory,
99
type TaskStoredContentBlock,
10+
type TaskStoredFileAttachment,
1011
type TaskStoredMessage,
1112
type TaskStoredToolCall,
1213
taskKeys,
1314
useChatHistory,
1415
} from '@/hooks/queries/tasks'
1516
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
1617
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
18+
import type { FileAttachmentForApi } from '../components/user-input/user-input'
1719
import type {
1820
ChatMessage,
21+
ChatMessageAttachment,
1922
ContentBlock,
2023
ContentBlockType,
2124
MothershipResource,
@@ -36,16 +39,7 @@ export interface UseChatReturn {
3639
messages: ChatMessage[]
3740
isSending: boolean
3841
error: string | null
39-
sendMessage: (
40-
message: string,
41-
fileAttachments?: Array<{
42-
id: string
43-
key: string
44-
filename: string
45-
media_type: string
46-
size: number
47-
}>
48-
) => Promise<void>
42+
sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[]) => Promise<void>
4943
stopGeneration: () => Promise<void>
5044
chatBottomRef: React.RefObject<HTMLDivElement | null>
5145
resources: MothershipResource[]
@@ -93,6 +87,18 @@ function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock {
9387
}
9488
}
9589

90+
function toDisplayAttachment(f: TaskStoredFileAttachment): ChatMessageAttachment {
91+
return {
92+
id: f.id,
93+
filename: f.filename,
94+
media_type: f.media_type,
95+
size: f.size,
96+
previewUrl: f.media_type.startsWith('image/')
97+
? `/api/files/serve/${encodeURIComponent(f.key)}?context=copilot`
98+
: undefined,
99+
}
100+
}
101+
96102
function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
97103
const mapped: ChatMessage = {
98104
id: msg.id,
@@ -120,6 +126,10 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
120126
mapped.contentBlocks = blocks
121127
}
122128

129+
if (Array.isArray(msg.fileAttachments) && msg.fileAttachments.length > 0) {
130+
mapped.attachments = msg.fileAttachments.map(toDisplayAttachment)
131+
}
132+
123133
return mapped
124134
}
125135

@@ -579,16 +589,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
579589
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
580590

581591
const sendMessage = useCallback(
582-
async (
583-
message: string,
584-
fileAttachments?: Array<{
585-
id: string
586-
key: string
587-
filename: string
588-
media_type: string
589-
size: number
590-
}>
591-
) => {
592+
async (message: string, fileAttachments?: FileAttachmentForApi[]) => {
592593
if (!message.trim() || !workspaceId) return
593594

594595
if (sendingRef.current) {
@@ -608,24 +609,40 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
608609
pendingUserMsgRef.current = { id: userMessageId, content: message }
609610
streamIdRef.current = userMessageId
610611

612+
const storedAttachments: TaskStoredFileAttachment[] | undefined =
613+
fileAttachments && fileAttachments.length > 0
614+
? fileAttachments.map((f) => ({
615+
id: f.id,
616+
key: f.key,
617+
filename: f.filename,
618+
media_type: f.media_type,
619+
size: f.size,
620+
}))
621+
: undefined
622+
611623
if (chatIdRef.current) {
624+
const cachedUserMsg: TaskStoredMessage = {
625+
id: userMessageId,
626+
role: 'user' as const,
627+
content: message,
628+
...(storedAttachments && { fileAttachments: storedAttachments }),
629+
}
612630
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatIdRef.current), (old) =>
613631
old
614632
? {
615633
...old,
616-
messages: [
617-
...old.messages,
618-
{ id: userMessageId, role: 'user' as const, content: message },
619-
],
634+
messages: [...old.messages, cachedUserMsg],
620635
activeStreamId: userMessageId,
621636
}
622637
: undefined
623638
)
624639
}
625640

641+
const userAttachments = storedAttachments?.map(toDisplayAttachment)
642+
626643
setMessages((prev) => [
627644
...prev,
628-
{ id: userMessageId, role: 'user', content: message },
645+
{ id: userMessageId, role: 'user', content: message, attachments: userAttachments },
629646
{ id: assistantId, role: 'assistant', content: '', contentBlocks: [] },
630647
])
631648

apps/sim/app/workspace/[workspaceId]/home/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,20 @@ export interface ContentBlock {
121121
options?: OptionItem[]
122122
}
123123

124+
export interface ChatMessageAttachment {
125+
id: string
126+
filename: string
127+
media_type: string
128+
size: number
129+
previewUrl?: string
130+
}
131+
124132
export interface ChatMessage {
125133
id: string
126134
role: 'user' | 'assistant'
127135
content: string
128136
contentBlocks?: ContentBlock[]
137+
attachments?: ChatMessageAttachment[]
129138
}
130139

131140
export const SUBAGENT_LABELS: Record<SubagentName, string> = {

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1913,7 +1913,7 @@ function CellContent({
19131913
return (
19141914
<>
19151915
{isEditing && (
1916-
<div className='absolute inset-0 z-10 flex items-center px-0'>
1916+
<div className='absolute inset-0 z-10 flex items-start px-0'>
19171917
<InlineEditor
19181918
value={value}
19191919
column={column}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
extractPathFromOutputId,
3131
parseOutputContentSafely,
3232
} from '@/lib/core/utils/response-format'
33+
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
3334
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
3435
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
3536
import { START_BLOCK_RESERVED_FIELDS } from '@/lib/workflows/types'
@@ -1049,7 +1050,7 @@ export function Chat() {
10491050
>
10501051
{/* File thumbnails */}
10511052
{chatFiles.length > 0 && (
1052-
<div className='mt-[4px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
1053+
<div className='mt-[4px] flex flex-wrap gap-[6px]'>
10531054
{chatFiles.map((file) => {
10541055
const previewUrl = getFilePreviewUrl(file)
10551056

@@ -1163,7 +1164,7 @@ export function Chat() {
11631164
id='floating-chat-file-input'
11641165
type='file'
11651166
multiple
1166-
accept='.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf,image/*'
1167+
accept={CHAT_ACCEPT_ATTRIBUTE}
11671168
onChange={handleFileInputChange}
11681169
className='hidden'
11691170
disabled={!activeWorkflowId || isExecuting}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useMemo } from 'react'
2+
import { FileText } from 'lucide-react'
23
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
34
import { useThrottledValue } from '@/hooks/use-throttled-value'
45

@@ -112,22 +113,22 @@ export function ChatMessage({ message }: ChatMessageProps) {
112113
}
113114

114115
if (message.type === 'user') {
116+
const hasAttachments = message.attachments && message.attachments.length > 0
115117
return (
116118
<div className='w-full max-w-full overflow-hidden opacity-100 transition-opacity duration-200'>
117-
{message.attachments && message.attachments.length > 0 && (
118-
<div className='mb-2 flex flex-wrap gap-[6px]'>
119-
{message.attachments.map((attachment) => {
119+
{hasAttachments && (
120+
<div className='mb-[4px] flex flex-wrap gap-[4px]'>
121+
{message.attachments!.map((attachment) => {
120122
const hasValidDataUrl =
121123
attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
122-
// Only treat as displayable image if we have both image type AND valid data URL
123124
const canDisplayAsImage = attachment.type.startsWith('image/') && hasValidDataUrl
124125

125126
return (
126127
<div
127128
key={attachment.id}
128-
className={`group relative flex-shrink-0 overflow-hidden rounded-[6px] bg-[var(--surface-2)] ${
129+
className={`flex max-w-[150px] items-center gap-[5px] rounded-[6px] bg-[var(--surface-2)] px-[5px] py-[3px] ${
129130
hasValidDataUrl ? 'cursor-pointer' : ''
130-
} ${canDisplayAsImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
131+
}`}
131132
onClick={(e) => {
132133
if (hasValidDataUrl) {
133134
e.preventDefault()
@@ -140,20 +141,14 @@ export function ChatMessage({ message }: ChatMessageProps) {
140141
<img
141142
src={attachment.dataUrl}
142143
alt={attachment.name}
143-
className='h-full w-full object-cover'
144+
className='h-[20px] w-[20px] flex-shrink-0 rounded-[3px] object-cover'
144145
/>
145146
) : (
146-
<div className='min-w-0 flex-1'>
147-
<div className='truncate font-medium text-[10px] text-[var(--white)]'>
148-
{attachment.name}
149-
</div>
150-
{attachment.size && (
151-
<div className='text-[9px] text-[var(--text-tertiary)]'>
152-
{formatFileSize(attachment.size)}
153-
</div>
154-
)}
155-
</div>
147+
<FileText className='h-[12px] w-[12px] flex-shrink-0 text-[var(--text-tertiary)]' />
156148
)}
149+
<span className='truncate text-[10px] text-[var(--text-secondary)]'>
150+
{attachment.name}
151+
</span>
157152
</div>
158153
)
159154
})}

0 commit comments

Comments
 (0)