1
+ "use client" ;
2
+
3
+ import { PropsWithChildren , useEffect , useState , type FC } from "react" ;
4
+ import { CircleXIcon , FileIcon , PaperclipIcon } from "lucide-react" ;
5
+ import {
6
+ AttachmentPrimitive ,
7
+ ComposerPrimitive ,
8
+ MessagePrimitive ,
9
+ useAttachment ,
10
+ } from "@assistant-ui/react" ;
11
+ import styled from "styled-components" ;
12
+ import {
13
+ Tooltip ,
14
+ TooltipContent ,
15
+ TooltipTrigger ,
16
+ } from "./tooltip" ;
17
+ import {
18
+ Dialog ,
19
+ DialogTitle ,
20
+ DialogTrigger ,
21
+ DialogOverlay ,
22
+ DialogPortal ,
23
+ DialogContent ,
24
+ } from "./dialog" ;
25
+ import { Avatar , AvatarImage , AvatarFallback } from "./avatar" ;
26
+ import { TooltipIconButton } from "../assistant-ui/tooltip-icon-button" ;
27
+
28
+ // ============================================================================
29
+ // STYLED COMPONENTS
30
+ // ============================================================================
31
+
32
+ const StyledDialogTrigger = styled ( DialogTrigger ) `
33
+ cursor: pointer;
34
+ transition: background-color 0.2s;
35
+ padding: 2px;
36
+ border-radius: 4px;
37
+
38
+ &:hover {
39
+ background-color: rgba(0, 0, 0, 0.05);
40
+ }
41
+ ` ;
42
+
43
+ const StyledAvatar = styled ( Avatar ) `
44
+ background-color: #f1f5f9;
45
+ display: flex;
46
+ width: 40px;
47
+ height: 40px;
48
+ align-items: center;
49
+ justify-content: center;
50
+ border-radius: 8px;
51
+ border: 1px solid #e2e8f0;
52
+ font-size: 14px;
53
+ ` ;
54
+
55
+ const AttachmentContainer = styled . div `
56
+ display: flex;
57
+ height: 48px;
58
+ width: 160px;
59
+ align-items: center;
60
+ justify-content: center;
61
+ gap: 8px;
62
+ border-radius: 8px;
63
+ border: 1px solid #e2e8f0;
64
+ padding: 4px;
65
+ ` ;
66
+
67
+ const AttachmentTextContainer = styled . div `
68
+ flex-grow: 1;
69
+ flex-basis: 0;
70
+ overflow: hidden;
71
+ ` ;
72
+
73
+ const AttachmentName = styled . p `
74
+ color: #64748b;
75
+ font-size: 12px;
76
+ font-weight: bold;
77
+ overflow: hidden;
78
+ text-overflow: ellipsis;
79
+ white-space: nowrap;
80
+ word-break: break-all;
81
+ margin: 0;
82
+ line-height: 16px;
83
+ ` ;
84
+
85
+ const AttachmentType = styled . p `
86
+ color: #64748b;
87
+ font-size: 12px;
88
+ margin: 0;
89
+ line-height: 16px;
90
+ ` ;
91
+
92
+ const AttachmentRoot = styled ( AttachmentPrimitive . Root ) `
93
+ position: relative;
94
+ margin-top: 12px;
95
+ ` ;
96
+
97
+ const StyledTooltipIconButton = styled ( TooltipIconButton ) `
98
+ color: #64748b;
99
+ position: absolute;
100
+ right: -12px;
101
+ top: -12px;
102
+ width: 24px;
103
+ height: 24px;
104
+
105
+ & svg {
106
+ background-color: white;
107
+ width: 16px;
108
+ height: 16px;
109
+ border-radius: 50%;
110
+ }
111
+ ` ;
112
+
113
+ const UserAttachmentsContainer = styled . div `
114
+ display: flex;
115
+ width: 100%;
116
+ flex-direction: row;
117
+ gap: 12px;
118
+ grid-column: 1 / -1;
119
+ grid-row-start: 1;
120
+ justify-content: flex-end;
121
+ ` ;
122
+
123
+ const ComposerAttachmentsContainer = styled . div `
124
+ display: flex;
125
+ width: 100%;
126
+ flex-direction: row;
127
+ gap: 12px;
128
+ overflow-x: auto;
129
+ ` ;
130
+
131
+ const StyledComposerButton = styled ( TooltipIconButton ) `
132
+ margin: 10px 0;
133
+ width: 32px;
134
+ height: 32px;
135
+ padding: 8px;
136
+ transition: opacity 0.2s ease-in;
137
+ ` ;
138
+
139
+ const ScreenReaderOnly = styled . span `
140
+ position: absolute;
141
+ left: -10000px;
142
+ width: 1px;
143
+ height: 1px;
144
+ overflow: hidden;
145
+ ` ;
146
+
147
+ // ============================================================================
148
+ // UTILITY HOOKS
149
+ // ============================================================================
150
+
151
+ // Simple replacement for useShallow (removes zustand dependency)
152
+ const useShallow = < T , > ( selector : ( state : any ) => T ) : ( ( state : any ) => T ) => selector ;
153
+
154
+ const useFileSrc = ( file : File | undefined ) => {
155
+ const [ src , setSrc ] = useState < string | undefined > ( undefined ) ;
156
+
157
+ useEffect ( ( ) => {
158
+ if ( ! file ) {
159
+ setSrc ( undefined ) ;
160
+ return ;
161
+ }
162
+
163
+ const objectUrl = URL . createObjectURL ( file ) ;
164
+ setSrc ( objectUrl ) ;
165
+
166
+ return ( ) => {
167
+ URL . revokeObjectURL ( objectUrl ) ;
168
+ } ;
169
+ } , [ file ] ) ;
170
+
171
+ return src ;
172
+ } ;
173
+
174
+ const useAttachmentSrc = ( ) => {
175
+ const { file, src } = useAttachment (
176
+ useShallow ( ( a ) : { file ?: File ; src ?: string } => {
177
+ if ( a . type !== "image" ) return { } ;
178
+ if ( a . file ) return { file : a . file } ;
179
+ const src = a . content ?. filter ( ( c : any ) => c . type === "image" ) [ 0 ] ?. image ;
180
+ if ( ! src ) return { } ;
181
+ return { src } ;
182
+ } )
183
+ ) ;
184
+
185
+ return useFileSrc ( file ) ?? src ;
186
+ } ;
187
+
188
+ // ============================================================================
189
+ // ATTACHMENT COMPONENTS
190
+ // ============================================================================
191
+
192
+ type AttachmentPreviewProps = {
193
+ src : string ;
194
+ } ;
195
+
196
+ const AttachmentPreview : FC < AttachmentPreviewProps > = ( { src } ) => {
197
+ const [ isLoaded , setIsLoaded ] = useState ( false ) ;
198
+
199
+ return (
200
+ < img
201
+ src = { src }
202
+ style = { {
203
+ width : "auto" ,
204
+ height : "auto" ,
205
+ maxWidth : "75dvh" ,
206
+ maxHeight : "75dvh" ,
207
+ display : isLoaded ? "block" : "none" ,
208
+ overflow : "clip" ,
209
+ } }
210
+ onLoad = { ( ) => setIsLoaded ( true ) }
211
+ alt = "Preview"
212
+ />
213
+ ) ;
214
+ } ;
215
+
216
+ const AttachmentPreviewDialog : FC < PropsWithChildren > = ( { children } ) => {
217
+ const src = useAttachmentSrc ( ) ;
218
+
219
+ if ( ! src ) return < > { children } </ > ;
220
+
221
+ return (
222
+ < Dialog >
223
+ < StyledDialogTrigger asChild >
224
+ { children }
225
+ </ StyledDialogTrigger >
226
+ < AttachmentDialogContent >
227
+ < DialogTitle >
228
+ < ScreenReaderOnly > Image Attachment Preview</ ScreenReaderOnly >
229
+ </ DialogTitle >
230
+ < AttachmentPreview src = { src } />
231
+ </ AttachmentDialogContent >
232
+ </ Dialog >
233
+ ) ;
234
+ } ;
235
+
236
+ const AttachmentThumb : FC = ( ) => {
237
+ const isImage = useAttachment ( ( a ) => a . type === "image" ) ;
238
+ const src = useAttachmentSrc ( ) ;
239
+ return (
240
+ < StyledAvatar >
241
+ < AvatarFallback delayMs = { isImage ? 200 : 0 } >
242
+ < FileIcon />
243
+ </ AvatarFallback >
244
+ < AvatarImage src = { src } />
245
+ </ StyledAvatar >
246
+ ) ;
247
+ } ;
248
+
249
+ const AttachmentUI : FC = ( ) => {
250
+ const canRemove = useAttachment ( ( a ) => a . source !== "message" ) ;
251
+ const typeLabel = useAttachment ( ( a ) => {
252
+ const type = a . type ;
253
+ switch ( type ) {
254
+ case "image" :
255
+ return "Image" ;
256
+ case "document" :
257
+ return "Document" ;
258
+ case "file" :
259
+ return "File" ;
260
+ default :
261
+ const _exhaustiveCheck : never = type ;
262
+ throw new Error ( `Unknown attachment type: ${ _exhaustiveCheck } ` ) ;
263
+ }
264
+ } ) ;
265
+
266
+ return (
267
+ < Tooltip >
268
+ < AttachmentRoot >
269
+ < AttachmentPreviewDialog >
270
+ < TooltipTrigger asChild >
271
+ < AttachmentContainer >
272
+ < AttachmentThumb />
273
+ < AttachmentTextContainer >
274
+ < AttachmentName >
275
+ < AttachmentPrimitive . Name />
276
+ </ AttachmentName >
277
+ < AttachmentType > { typeLabel } </ AttachmentType >
278
+ </ AttachmentTextContainer >
279
+ </ AttachmentContainer >
280
+ </ TooltipTrigger >
281
+ </ AttachmentPreviewDialog >
282
+ { canRemove && < AttachmentRemove /> }
283
+ </ AttachmentRoot >
284
+ < TooltipContent side = "top" >
285
+ < AttachmentPrimitive . Name />
286
+ </ TooltipContent >
287
+ </ Tooltip >
288
+ ) ;
289
+ } ;
290
+
291
+ const AttachmentRemove : FC = ( ) => {
292
+ return (
293
+ < AttachmentPrimitive . Remove asChild >
294
+ < StyledTooltipIconButton
295
+ tooltip = "Remove file"
296
+ side = "top"
297
+ >
298
+ < CircleXIcon />
299
+ </ StyledTooltipIconButton >
300
+ </ AttachmentPrimitive . Remove >
301
+ ) ;
302
+ } ;
303
+
304
+ // ============================================================================
305
+ // EXPORTED COMPONENTS
306
+ // ============================================================================
307
+
308
+ export const UserMessageAttachments : FC = ( ) => {
309
+ return (
310
+ < UserAttachmentsContainer >
311
+ < MessagePrimitive . Attachments components = { { Attachment : AttachmentUI } } />
312
+ </ UserAttachmentsContainer >
313
+ ) ;
314
+ } ;
315
+
316
+ export const ComposerAttachments : FC = ( ) => {
317
+ return (
318
+ < ComposerAttachmentsContainer >
319
+ < ComposerPrimitive . Attachments
320
+ components = { { Attachment : AttachmentUI } }
321
+ />
322
+ </ ComposerAttachmentsContainer >
323
+ ) ;
324
+ } ;
325
+
326
+ export const ComposerAddAttachment : FC = ( ) => {
327
+ return (
328
+ < ComposerPrimitive . AddAttachment asChild >
329
+ < StyledComposerButton
330
+ tooltip = "Add Attachment"
331
+ variant = "ghost"
332
+ >
333
+ < PaperclipIcon />
334
+ </ StyledComposerButton >
335
+ </ ComposerPrimitive . AddAttachment >
336
+ ) ;
337
+ } ;
338
+
339
+ const AttachmentDialogContent : FC < PropsWithChildren > = ( { children } ) => (
340
+ < DialogPortal >
341
+ < DialogOverlay />
342
+ < DialogContent className = "aui-dialog-content" >
343
+ { children }
344
+ </ DialogContent >
345
+ </ DialogPortal >
346
+ ) ;
0 commit comments