clawport-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,1047 @@
1
+ 'use client'
2
+ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import type { Agent } from '@/lib/types'
5
+ import type { Conversation, ConversationStore, Message, MediaAttachment } from '@/lib/conversations'
6
+ import { parseMedia, addMessage, updateLastMessage } from '@/lib/conversations'
7
+ import { buildApiContent } from '@/lib/multimodal'
8
+ import { useSettings } from '@/app/settings-provider'
9
+ import { FileAttachment } from './FileAttachment'
10
+ import { MediaPreview } from './MediaPreview'
11
+ import { AgentAvatar } from '@/components/AgentAvatar'
12
+
13
+ interface ConversationViewProps {
14
+ agent: Agent
15
+ conversation: Conversation
16
+ onUpdate: (agentId: string, updater: (prev: ConversationStore) => ConversationStore) => void
17
+ onBack?: () => void
18
+ }
19
+
20
+ /* ── Markdown rendering ──────────────────────────────────── */
21
+
22
+ function inlineFormat(text: string): React.ReactNode {
23
+ const parts: React.ReactNode[] = []
24
+ // Match URLs, bold, inline code, italic — in priority order
25
+ const regex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)}\]'"])|(\*\*(.+?)\*\*)|(`([^`]+)`)|\*([^*]+)\*/g
26
+ let last = 0
27
+ let match
28
+
29
+ while ((match = regex.exec(text)) !== null) {
30
+ if (match.index > last) parts.push(text.slice(last, match.index))
31
+ if (match[1]) {
32
+ // URL
33
+ parts.push(
34
+ <a
35
+ key={match.index}
36
+ href={match[1]}
37
+ target="_blank"
38
+ rel="noopener noreferrer"
39
+ style={{ color: 'var(--system-blue)', textDecoration: 'underline', textUnderlineOffset: 2 }}
40
+ >
41
+ {match[1]}
42
+ </a>
43
+ )
44
+ } else if (match[2]) {
45
+ // Bold
46
+ parts.push(<strong key={match.index} style={{ fontWeight: 'var(--weight-bold)' }}>{match[3]}</strong>)
47
+ } else if (match[4]) {
48
+ // Inline code
49
+ parts.push(
50
+ <code key={match.index} style={{
51
+ background: 'var(--code-bg)',
52
+ border: '1px solid var(--code-border)',
53
+ borderRadius: 5,
54
+ padding: '1px 5px',
55
+ fontSize: '0.88em',
56
+ fontFamily: '"SF Mono", Menlo, monospace',
57
+ color: 'var(--code-text)',
58
+ }}>{match[5]}</code>
59
+ )
60
+ } else if (match[6]) {
61
+ // Italic
62
+ parts.push(<em key={match.index} style={{ fontStyle: 'italic', opacity: 0.85 }}>{match[6]}</em>)
63
+ }
64
+ last = match.index + match[0].length
65
+ }
66
+ if (last < text.length) parts.push(text.slice(last))
67
+ return parts.length === 1 ? parts[0] : <>{parts}</>
68
+ }
69
+
70
+ function CodeBlock({ code, keyProp }: { code: string; keyProp: number }) {
71
+ const [copied, setCopied] = useState(false)
72
+
73
+ function handleCopy() {
74
+ navigator.clipboard.writeText(code).then(() => {
75
+ setCopied(true)
76
+ setTimeout(() => setCopied(false), 1500)
77
+ })
78
+ }
79
+
80
+ return (
81
+ <div key={keyProp} className="code-block-wrapper">
82
+ <button
83
+ className="code-copy-btn focus-ring"
84
+ onClick={handleCopy}
85
+ aria-label="Copy code"
86
+ >
87
+ {copied ? 'Copied!' : 'Copy'}
88
+ </button>
89
+ <pre><code>{code}</code></pre>
90
+ </div>
91
+ )
92
+ }
93
+
94
+ function formatMessage(content: string): React.ReactNode {
95
+ if (!content) return null
96
+ const lines = content.split('\n')
97
+ const result: React.ReactNode[] = []
98
+ let inCodeBlock = false
99
+ let codeLines: string[] = []
100
+
101
+ for (let i = 0; i < lines.length; i++) {
102
+ const line = lines[i]
103
+ if (line.startsWith('```')) {
104
+ if (!inCodeBlock) {
105
+ inCodeBlock = true
106
+ codeLines = []
107
+ } else {
108
+ inCodeBlock = false
109
+ result.push(<CodeBlock key={i} keyProp={i} code={codeLines.join('\n')} />)
110
+ codeLines = []
111
+ }
112
+ continue
113
+ }
114
+ if (inCodeBlock) { codeLines.push(line); continue }
115
+ if (line.trim() === '') { result.push(<div key={`space-${i}`} style={{ height: 6 }} />); continue }
116
+ if (line.match(/^[-*] /)) {
117
+ result.push(
118
+ <div key={i} style={{ display: 'flex', gap: 'var(--space-2)', marginBottom: 2 }}>
119
+ <span style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 1 }}>&bull;</span>
120
+ <span>{inlineFormat(line.slice(2))}</span>
121
+ </div>
122
+ )
123
+ continue
124
+ }
125
+ if (line.match(/^\d+\. /)) {
126
+ const num = line.match(/^(\d+)\. /)?.[1]
127
+ result.push(
128
+ <div key={i} style={{ display: 'flex', gap: 'var(--space-2)', marginBottom: 2 }}>
129
+ <span style={{ color: 'var(--accent)', flexShrink: 0, fontWeight: 'var(--weight-semibold)', minWidth: 16 }}>{num}.</span>
130
+ <span>{inlineFormat(line.replace(/^\d+\. /, ''))}</span>
131
+ </div>
132
+ )
133
+ continue
134
+ }
135
+ if (line.startsWith('### ')) {
136
+ result.push(
137
+ <div key={i} style={{ fontWeight: 'var(--weight-semibold)', fontSize: 'var(--text-footnote)', marginTop: 'var(--space-2)', marginBottom: 2 }}>
138
+ {inlineFormat(line.slice(4))}
139
+ </div>
140
+ )
141
+ continue
142
+ }
143
+ if (line.startsWith('## ')) {
144
+ result.push(
145
+ <div key={i} style={{ fontWeight: 'var(--weight-bold)', fontSize: 'var(--text-subheadline)', marginTop: 'var(--space-3)', marginBottom: 3 }}>
146
+ {inlineFormat(line.slice(3))}
147
+ </div>
148
+ )
149
+ continue
150
+ }
151
+ if (line.startsWith('# ')) {
152
+ result.push(
153
+ <div key={i} style={{ fontWeight: 'var(--weight-bold)', fontSize: 'var(--text-body)', marginTop: 'var(--space-3)', marginBottom: 'var(--space-1)' }}>
154
+ {inlineFormat(line.slice(2))}
155
+ </div>
156
+ )
157
+ continue
158
+ }
159
+ result.push(<div key={i} style={{ marginBottom: 1 }}>{inlineFormat(line)}</div>)
160
+ }
161
+ return <>{result}</>
162
+ }
163
+
164
+ /* ── Timestamp formatting ──────────────────────────────── */
165
+
166
+ function formatTimestamp(ts: number): string {
167
+ const now = new Date()
168
+ const date = new Date(ts)
169
+ const isToday = now.toDateString() === date.toDateString()
170
+ const yesterday = new Date(now)
171
+ yesterday.setDate(yesterday.getDate() - 1)
172
+ const isYesterday = yesterday.toDateString() === date.toDateString()
173
+ const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
174
+
175
+ if (isToday) return `Today ${time}`
176
+ if (isYesterday) return `Yesterday ${time}`
177
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ` ${time}`
178
+ }
179
+
180
+ function shouldShowTimestamp(messages: Message[], index: number): boolean {
181
+ if (index === 0) return true
182
+ const gap = messages[index].timestamp - messages[index - 1].timestamp
183
+ return gap > 5 * 60 * 1000 // 5 minutes
184
+ }
185
+
186
+ function shouldShowAvatar(messages: Message[], index: number): boolean {
187
+ if (index === 0) return true
188
+ return messages[index - 1].role !== messages[index].role
189
+ }
190
+
191
+ /* ── Helper: convert File to base64 MediaAttachment ────── */
192
+
193
+ async function fileToAttachment(file: File): Promise<MediaAttachment> {
194
+ const isImage = file.type.startsWith('image/')
195
+ const isAudio = file.type.startsWith('audio/')
196
+
197
+ let dataUrl: string
198
+ if (isImage) {
199
+ // Resize images to max 1200px — reduces base64 size for API transport
200
+ dataUrl = await resizeImage(file, 1200)
201
+ } else {
202
+ dataUrl = await new Promise<string>((resolve, reject) => {
203
+ const reader = new FileReader()
204
+ reader.onloadend = () => resolve(reader.result as string)
205
+ reader.onerror = reject
206
+ reader.readAsDataURL(file)
207
+ })
208
+ }
209
+
210
+ return {
211
+ type: isImage ? 'image' : isAudio ? 'audio' : 'file',
212
+ url: dataUrl,
213
+ name: file.name,
214
+ mimeType: file.type,
215
+ size: dataUrl.length,
216
+ }
217
+ }
218
+
219
+ /** Resize an image file to fit within maxPx on the longest side. Returns a data URL. */
220
+ function resizeImage(file: File, maxPx: number): Promise<string> {
221
+ return new Promise((resolve, reject) => {
222
+ const img = new Image()
223
+ const url = URL.createObjectURL(file)
224
+ img.onload = () => {
225
+ URL.revokeObjectURL(url)
226
+ let { width, height } = img
227
+ if (width > maxPx || height > maxPx) {
228
+ const scale = maxPx / Math.max(width, height)
229
+ width = Math.round(width * scale)
230
+ height = Math.round(height * scale)
231
+ }
232
+ const canvas = document.createElement('canvas')
233
+ canvas.width = width
234
+ canvas.height = height
235
+ const ctx = canvas.getContext('2d')
236
+ if (!ctx) { reject(new Error('no canvas context')); return }
237
+ ctx.drawImage(img, 0, 0, width, height)
238
+ // Use JPEG for photos (smaller), PNG for small images
239
+ const mimeType = file.size > 50000 ? 'image/jpeg' : 'image/png'
240
+ const quality = mimeType === 'image/jpeg' ? 0.85 : undefined
241
+ resolve(canvas.toDataURL(mimeType, quality))
242
+ }
243
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('image load failed')) }
244
+ img.src = url
245
+ })
246
+ }
247
+
248
+ /* ── Render media helpers ─────────────────────────────── */
249
+
250
+ function renderMedia(media: MediaAttachment[], isUser: boolean) {
251
+ const images = media.filter(m => m.type === 'image')
252
+ const files = media.filter(m => m.type === 'file')
253
+
254
+ return (
255
+ <>
256
+ {images.map((m, mi) => (
257
+ <div key={`img-${mi}`} style={{
258
+ marginTop: 'var(--space-2)',
259
+ borderRadius: 'var(--radius-lg)',
260
+ overflow: 'hidden',
261
+ maxWidth: 280,
262
+ }}>
263
+ <img
264
+ src={m.url}
265
+ alt={m.name || 'Image'}
266
+ style={{ width: '100%', display: 'block', borderRadius: 'var(--radius-lg)', cursor: 'pointer' }}
267
+ onClick={() => window.open(m.url, '_blank')}
268
+ />
269
+ </div>
270
+ ))}
271
+ {files.map((m, mi) => (
272
+ <div key={`file-${mi}`} style={{ marginTop: 'var(--space-2)' }}>
273
+ <FileAttachment
274
+ name={m.name || 'File'}
275
+ size={m.size}
276
+ mimeType={m.mimeType}
277
+ url={m.url}
278
+ isUser={isUser}
279
+ />
280
+ </div>
281
+ ))}
282
+ </>
283
+ )
284
+ }
285
+
286
+ /* ── Component ──────────────────────────────────────────── */
287
+
288
+ export function ConversationView({ agent, conversation, onUpdate, onBack }: ConversationViewProps) {
289
+ const router = useRouter()
290
+ const { settings } = useSettings()
291
+ const [input, setInput] = useState('')
292
+ const [isStreaming, setIsStreaming] = useState(false)
293
+ const [pendingAttachments, setPendingAttachments] = useState<MediaAttachment[]>([])
294
+ const [isDragOver, setIsDragOver] = useState(false)
295
+ const bottomRef = useRef<HTMLDivElement>(null)
296
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
297
+ const fileInputRef = useRef<HTMLInputElement>(null)
298
+ const messagesAreaRef = useRef<HTMLDivElement>(null)
299
+
300
+ const messages = conversation?.messages || []
301
+ const messagesRef = useRef(messages)
302
+ messagesRef.current = messages
303
+
304
+ useEffect(() => {
305
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
306
+ }, [messages])
307
+
308
+ const sendMessage = useCallback(async (mediaOverride?: MediaAttachment[], contentOverride?: string) => {
309
+ const mediaToSend = mediaOverride || [...pendingAttachments]
310
+ const hasText = input.trim().length > 0 || !!contentOverride
311
+ const hasMedia = mediaToSend.length > 0
312
+
313
+ if ((!hasText && !hasMedia) || isStreaming) return
314
+
315
+ const text = contentOverride || input.trim()
316
+ setInput('')
317
+ setPendingAttachments([])
318
+
319
+ // Reset textarea height
320
+ if (textareaRef.current) {
321
+ textareaRef.current.style.height = 'auto'
322
+ }
323
+
324
+ // Build content label for media-only messages
325
+ let content = text
326
+ if (!content && hasMedia) {
327
+ const labels = mediaToSend.map(m => `[${m.name || m.type}]`)
328
+ content = labels.join(' ')
329
+ }
330
+
331
+ const userMsg: Message = {
332
+ id: crypto.randomUUID(),
333
+ role: 'user',
334
+ content,
335
+ timestamp: Date.now(),
336
+ media: hasMedia ? mediaToSend : undefined,
337
+ }
338
+
339
+ const assistantMsgId = crypto.randomUUID()
340
+ const assistantMsg: Message = {
341
+ id: assistantMsgId,
342
+ role: 'assistant',
343
+ content: '',
344
+ timestamp: Date.now(),
345
+ isStreaming: true,
346
+ }
347
+
348
+ onUpdate(agent.id, prev => {
349
+ let next = addMessage(prev, agent.id, userMsg)
350
+ next = addMessage(next, agent.id, assistantMsg)
351
+ return next
352
+ })
353
+
354
+ setIsStreaming(true)
355
+
356
+ // Use ref to read latest messages (avoids stale closure)
357
+ const apiMessages = [...messagesRef.current, userMsg].map(m => ({
358
+ role: m.role,
359
+ content: buildApiContent(m),
360
+ }))
361
+
362
+ try {
363
+ const res = await fetch(`/api/chat/${agent.id}`, {
364
+ method: 'POST',
365
+ headers: { 'Content-Type': 'application/json' },
366
+ body: JSON.stringify({ messages: apiMessages, operatorName: settings.operatorName }),
367
+ })
368
+
369
+ if (!res.ok || !res.body) throw new Error('Stream failed')
370
+
371
+ const reader = res.body.getReader()
372
+ const decoder = new TextDecoder()
373
+ let buffer = ''
374
+ let fullContent = ''
375
+
376
+ while (true) {
377
+ const { done, value } = await reader.read()
378
+ if (done) break
379
+ buffer += decoder.decode(value, { stream: true })
380
+ const lines = buffer.split('\n')
381
+ buffer = lines.pop() || ''
382
+ for (const line of lines) {
383
+ if (line.startsWith('data: ') && line !== 'data: [DONE]') {
384
+ try {
385
+ const chunk = JSON.parse(line.slice(6))
386
+ if (chunk.content) {
387
+ fullContent += chunk.content
388
+ const capturedContent = fullContent
389
+ onUpdate(agent.id, prev => updateLastMessage(prev, agent.id, assistantMsgId, capturedContent, true))
390
+ }
391
+ } catch { /* skip malformed chunks */ }
392
+ }
393
+ }
394
+ }
395
+
396
+ const finalContent = fullContent
397
+ onUpdate(agent.id, prev => updateLastMessage(prev, agent.id, assistantMsgId, finalContent, false))
398
+ } catch {
399
+ onUpdate(agent.id, prev => updateLastMessage(prev, agent.id, assistantMsgId, 'Error getting response. Check API connection.', false))
400
+ } finally {
401
+ setIsStreaming(false)
402
+ textareaRef.current?.focus()
403
+ }
404
+ }, [input, pendingAttachments, isStreaming, agent.id, onUpdate])
405
+
406
+ function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
407
+ if (e.key === 'Escape') {
408
+ e.preventDefault()
409
+ textareaRef.current?.blur()
410
+ return
411
+ }
412
+ if (e.key === 'Enter' && !e.shiftKey) {
413
+ e.preventDefault()
414
+ sendMessage()
415
+ }
416
+ }
417
+
418
+ async function handleFileAttach(e: React.ChangeEvent<HTMLInputElement>) {
419
+ const files = e.target.files
420
+ if (!files || files.length === 0) return
421
+
422
+ const newAttachments: MediaAttachment[] = []
423
+ for (let i = 0; i < files.length; i++) {
424
+ newAttachments.push(await fileToAttachment(files[i]))
425
+ }
426
+ setPendingAttachments(prev => [...prev, ...newAttachments])
427
+ e.target.value = ''
428
+ }
429
+
430
+ function removePendingAttachment(index: number) {
431
+ setPendingAttachments(prev => prev.filter((_, i) => i !== index))
432
+ }
433
+
434
+ /* ── Clipboard paste handler ──────────────────────────── */
435
+
436
+ async function handlePaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
437
+ const items = e.clipboardData?.items
438
+ if (!items) return
439
+
440
+ for (let i = 0; i < items.length; i++) {
441
+ if (items[i].type.startsWith('image/')) {
442
+ e.preventDefault()
443
+ const file = items[i].getAsFile()
444
+ if (file) {
445
+ const att = await fileToAttachment(file)
446
+ setPendingAttachments(prev => [...prev, att])
447
+ }
448
+ return
449
+ }
450
+ }
451
+ }
452
+
453
+ /* ── Drag and drop handlers ────────────────────────────── */
454
+
455
+ function handleDragOver(e: React.DragEvent) {
456
+ e.preventDefault()
457
+ e.stopPropagation()
458
+ setIsDragOver(true)
459
+ }
460
+
461
+ function handleDragLeave(e: React.DragEvent) {
462
+ e.preventDefault()
463
+ e.stopPropagation()
464
+ // Only leave if we're actually leaving the container
465
+ const rect = messagesAreaRef.current?.getBoundingClientRect()
466
+ if (rect) {
467
+ const { clientX, clientY } = e
468
+ if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
469
+ setIsDragOver(false)
470
+ }
471
+ }
472
+ }
473
+
474
+ async function handleDrop(e: React.DragEvent) {
475
+ e.preventDefault()
476
+ e.stopPropagation()
477
+ setIsDragOver(false)
478
+
479
+ const files = e.dataTransfer?.files
480
+ if (!files || files.length === 0) return
481
+
482
+ const newAttachments: MediaAttachment[] = []
483
+ for (let i = 0; i < files.length; i++) {
484
+ newAttachments.push(await fileToAttachment(files[i]))
485
+ }
486
+ setPendingAttachments(prev => [...prev, ...newAttachments])
487
+ }
488
+
489
+ /* ── TTS playback ─────────────────────────────────────── */
490
+
491
+ const [ttsLoadingId, setTtsLoadingId] = useState<string | null>(null)
492
+ const [ttsPlayingId, setTtsPlayingId] = useState<string | null>(null)
493
+ const ttsAudioRef = useRef<HTMLAudioElement | null>(null)
494
+ const ttsObjectUrlRef = useRef<string | null>(null)
495
+
496
+ useEffect(() => {
497
+ return () => {
498
+ ttsAudioRef.current?.pause()
499
+ if (ttsObjectUrlRef.current) URL.revokeObjectURL(ttsObjectUrlRef.current)
500
+ }
501
+ }, [])
502
+
503
+ const stopTts = useCallback(() => {
504
+ if (ttsAudioRef.current) {
505
+ ttsAudioRef.current.pause()
506
+ ttsAudioRef.current.currentTime = 0
507
+ ttsAudioRef.current = null
508
+ }
509
+ if (ttsObjectUrlRef.current) {
510
+ URL.revokeObjectURL(ttsObjectUrlRef.current)
511
+ ttsObjectUrlRef.current = null
512
+ }
513
+ setTtsPlayingId(null)
514
+ setTtsLoadingId(null)
515
+ }, [])
516
+
517
+ const playTts = useCallback(async (msgId: string, text: string) => {
518
+ if (ttsPlayingId === msgId) { stopTts(); return }
519
+ stopTts()
520
+ setTtsLoadingId(msgId)
521
+
522
+ try {
523
+ const res = await fetch('/api/tts', {
524
+ method: 'POST',
525
+ headers: { 'Content-Type': 'application/json' },
526
+ body: JSON.stringify({ text }),
527
+ })
528
+ if (!res.ok) throw new Error('TTS request failed')
529
+
530
+ const blob = await res.blob()
531
+ const url = URL.createObjectURL(blob)
532
+ ttsObjectUrlRef.current = url
533
+
534
+ const audio = new Audio(url)
535
+ ttsAudioRef.current = audio
536
+
537
+ audio.onended = () => {
538
+ setTtsPlayingId(null)
539
+ ttsAudioRef.current = null
540
+ if (ttsObjectUrlRef.current) {
541
+ URL.revokeObjectURL(ttsObjectUrlRef.current)
542
+ ttsObjectUrlRef.current = null
543
+ }
544
+ }
545
+ audio.onerror = () => stopTts()
546
+
547
+ await audio.play()
548
+ setTtsLoadingId(null)
549
+ setTtsPlayingId(msgId)
550
+ } catch {
551
+ stopTts()
552
+ }
553
+ }, [ttsPlayingId, stopTts])
554
+
555
+ const speakerPlayIcon = useMemo(() => (
556
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
557
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
558
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
559
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
560
+ </svg>
561
+ ), [])
562
+
563
+ const speakerStopIcon = useMemo(() => (
564
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
565
+ <rect x="6" y="6" width="12" height="12" rx="2" />
566
+ </svg>
567
+ ), [])
568
+
569
+ function clearChat() {
570
+ onUpdate(agent.id, prev => ({
571
+ ...prev,
572
+ [agent.id]: {
573
+ agentId: agent.id,
574
+ messages: [{
575
+ id: crypto.randomUUID(),
576
+ role: 'assistant' as const,
577
+ content: `I'm ${agent.name}. ${agent.description} What do you need?`,
578
+ timestamp: Date.now(),
579
+ }],
580
+ unread: 0,
581
+ lastActivity: Date.now(),
582
+ }
583
+ }))
584
+ }
585
+
586
+ const hasContent = input.trim().length > 0 || pendingAttachments.length > 0
587
+
588
+ return (
589
+ <div style={{
590
+ flex: 1,
591
+ display: 'flex',
592
+ flexDirection: 'column',
593
+ height: '100%',
594
+ background: 'var(--bg)',
595
+ }}>
596
+ {/* ── Header ─────────────────────────────────── */}
597
+ <div style={{
598
+ height: 52,
599
+ display: 'flex',
600
+ alignItems: 'center',
601
+ padding: '0 var(--space-4)',
602
+ borderBottom: '1px solid var(--separator)',
603
+ background: 'var(--material-thick)',
604
+ backdropFilter: 'blur(20px)',
605
+ WebkitBackdropFilter: 'blur(20px)',
606
+ position: 'sticky',
607
+ top: 0,
608
+ zIndex: 10,
609
+ flexShrink: 0,
610
+ }}>
611
+ {/* Mobile back button */}
612
+ {onBack && (
613
+ <button
614
+ className="md:hidden btn-ghost focus-ring"
615
+ onClick={onBack}
616
+ aria-label="Back to agents"
617
+ style={{
618
+ padding: 'var(--space-1) var(--space-2)',
619
+ borderRadius: 'var(--radius-sm)',
620
+ marginRight: 'var(--space-2)',
621
+ fontSize: 'var(--text-subheadline)',
622
+ display: 'flex',
623
+ alignItems: 'center',
624
+ gap: 'var(--space-1)',
625
+ }}
626
+ >
627
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
628
+ <polyline points="15 18 9 12 15 6" />
629
+ </svg>
630
+ Back
631
+ </button>
632
+ )}
633
+
634
+ {/* Agent info */}
635
+ <div style={{
636
+ display: 'flex',
637
+ alignItems: 'center',
638
+ gap: 'var(--space-3)',
639
+ flex: 1,
640
+ minWidth: 0,
641
+ }}>
642
+ <AgentAvatar agent={agent} size={32} borderRadius={16} />
643
+ <div style={{ minWidth: 0 }}>
644
+ <div style={{
645
+ fontSize: 'var(--text-subheadline)',
646
+ fontWeight: 'var(--weight-semibold)',
647
+ color: 'var(--text-primary)',
648
+ letterSpacing: '-0.2px',
649
+ lineHeight: 1.2,
650
+ }}>
651
+ {agent.name}
652
+ </div>
653
+ <div style={{
654
+ fontSize: 'var(--text-caption2)',
655
+ color: 'var(--text-tertiary)',
656
+ lineHeight: 1.2,
657
+ overflow: 'hidden',
658
+ textOverflow: 'ellipsis',
659
+ whiteSpace: 'nowrap',
660
+ }}>
661
+ {agent.title}
662
+ </div>
663
+ </div>
664
+ </div>
665
+
666
+ {/* Actions */}
667
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
668
+ <button
669
+ className="btn-ghost focus-ring"
670
+ aria-label="View agent profile"
671
+ onClick={() => router.push(`/agents/${agent.id}`)}
672
+ style={{
673
+ padding: 'var(--space-2)',
674
+ borderRadius: 'var(--radius-sm)',
675
+ display: 'flex',
676
+ alignItems: 'center',
677
+ justifyContent: 'center',
678
+ }}
679
+ >
680
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
681
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
682
+ <polyline points="15 3 21 3 21 9" />
683
+ <line x1="10" y1="14" x2="21" y2="3" />
684
+ </svg>
685
+ </button>
686
+ <button
687
+ className="btn-ghost focus-ring"
688
+ aria-label="Clear conversation"
689
+ onClick={clearChat}
690
+ style={{
691
+ padding: 'var(--space-2)',
692
+ borderRadius: 'var(--radius-sm)',
693
+ display: 'flex',
694
+ alignItems: 'center',
695
+ justifyContent: 'center',
696
+ }}
697
+ >
698
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
699
+ <polyline points="3 6 5 6 21 6" />
700
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
701
+ </svg>
702
+ </button>
703
+ </div>
704
+ </div>
705
+
706
+ {/* ── Messages ──────────────────────────────── */}
707
+ <div
708
+ ref={messagesAreaRef}
709
+ onDragOver={handleDragOver}
710
+ onDragLeave={handleDragLeave}
711
+ onDrop={handleDrop}
712
+ style={{
713
+ flex: 1,
714
+ overflowY: 'auto',
715
+ background: 'var(--bg)',
716
+ padding: 'var(--space-5) 0 var(--space-16) 0',
717
+ position: 'relative',
718
+ }}
719
+ >
720
+ {/* Drag overlay */}
721
+ {isDragOver && (
722
+ <div style={{
723
+ position: 'absolute',
724
+ inset: 0,
725
+ background: 'var(--accent-fill)',
726
+ border: '2px dashed var(--accent)',
727
+ borderRadius: 'var(--radius-md)',
728
+ margin: 'var(--space-4)',
729
+ display: 'flex',
730
+ alignItems: 'center',
731
+ justifyContent: 'center',
732
+ zIndex: 5,
733
+ pointerEvents: 'none',
734
+ }}>
735
+ <div style={{
736
+ fontSize: 'var(--text-subheadline)',
737
+ fontWeight: 'var(--weight-semibold)',
738
+ color: 'var(--accent)',
739
+ }}>
740
+ Drop files to attach
741
+ </div>
742
+ </div>
743
+ )}
744
+
745
+ {messages.map((msg, i) => {
746
+ const isUser = msg.role === 'user'
747
+ const showAvatar = shouldShowAvatar(messages, i)
748
+ const showTimestamp = shouldShowTimestamp(messages, i)
749
+ const isLastAssistant = !isUser && i === messages.length - 1 && (isStreaming || msg.isStreaming)
750
+ const showTypingDots = isLastAssistant && !msg.content
751
+ const media = msg.media || parseMedia(msg.content)
752
+
753
+ // Strip media URLs from text for display
754
+ let textContent = msg.content
755
+ if (media.length > 0 && !msg.media) {
756
+ media.forEach(m => {
757
+ textContent = textContent.replace(m.url, '')
758
+ textContent = textContent.replace(/!\[[^\]]*\]\([^\)]+\)/g, '')
759
+ })
760
+ textContent = textContent.trim()
761
+ }
762
+ // Hide auto-generated content labels for media-only messages
763
+ if (msg.media && msg.media.length > 0) {
764
+ const isAutoLabel = textContent.startsWith('[') && textContent.endsWith(']')
765
+ if (isAutoLabel) textContent = ''
766
+ }
767
+
768
+ return (
769
+ <div key={msg.id || i} className="animate-fade-in">
770
+ {/* Timestamp divider */}
771
+ {showTimestamp && (
772
+ <div style={{
773
+ textAlign: 'center',
774
+ padding: 'var(--space-3) 0',
775
+ fontSize: 'var(--text-caption2)',
776
+ color: 'var(--text-tertiary)',
777
+ }}>
778
+ {formatTimestamp(msg.timestamp)}
779
+ </div>
780
+ )}
781
+
782
+ {/* Spacing between role switches */}
783
+ {!showTimestamp && i > 0 && (
784
+ <div style={{ height: messages[i - 1].role !== msg.role ? 'var(--space-4)' : 'var(--space-1)' }} />
785
+ )}
786
+
787
+ {/* User message */}
788
+ {isUser && (
789
+ <div style={{
790
+ display: 'flex',
791
+ flexDirection: 'column',
792
+ alignItems: 'flex-end',
793
+ padding: '0 var(--space-4)',
794
+ marginBottom: 'var(--space-1)',
795
+ }}>
796
+ {textContent && (
797
+ <div className="msg-user" style={{
798
+ maxWidth: '75%',
799
+ padding: 'var(--space-3) var(--space-4)',
800
+ borderRadius: 'var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg)',
801
+ background: 'var(--accent)',
802
+ color: 'var(--accent-contrast)',
803
+ fontSize: 'var(--text-subheadline)',
804
+ lineHeight: 'var(--leading-relaxed)',
805
+ fontWeight: 'var(--weight-medium)',
806
+ boxShadow: 'var(--shadow-subtle)',
807
+ }}>
808
+ {textContent}
809
+ </div>
810
+ )}
811
+ {media.length > 0 && (
812
+ <div style={{ maxWidth: '75%' }}>
813
+ {renderMedia(media, true)}
814
+ </div>
815
+ )}
816
+ </div>
817
+ )}
818
+
819
+ {/* Assistant message */}
820
+ {!isUser && (
821
+ <div style={{
822
+ display: 'flex',
823
+ justifyContent: 'flex-start',
824
+ padding: '0 var(--space-4)',
825
+ marginBottom: 'var(--space-1)',
826
+ }}>
827
+ {/* Small avatar */}
828
+ <div style={{
829
+ flexShrink: 0,
830
+ width: 28,
831
+ marginRight: 'var(--space-2)',
832
+ }}>
833
+ {showAvatar ? (
834
+ <AgentAvatar agent={agent} size={28} borderRadius={14} />
835
+ ) : <div style={{ width: 28 }} />}
836
+ </div>
837
+
838
+ <div style={{ maxWidth: '75%', display: 'flex', flexDirection: 'column' }}>
839
+ {/* Typing indicator */}
840
+ {showTypingDots && (
841
+ <div className="msg-assistant" style={{
842
+ padding: 'var(--space-3) var(--space-4)',
843
+ borderRadius: 'var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-lg)',
844
+ background: 'var(--material-thin)',
845
+ border: '1px solid var(--separator)',
846
+ }}>
847
+ <div style={{ display: 'flex', gap: 4, alignItems: 'center', height: 16 }}>
848
+ <span className="typing-dot" style={{ animationDelay: '0ms' }} />
849
+ <span className="typing-dot" style={{ animationDelay: '150ms' }} />
850
+ <span className="typing-dot" style={{ animationDelay: '300ms' }} />
851
+ </div>
852
+ </div>
853
+ )}
854
+
855
+ {/* Text bubble */}
856
+ {textContent && (
857
+ <div className="msg-assistant" style={{
858
+ padding: 'var(--space-3) var(--space-4)',
859
+ borderRadius: 'var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-lg)',
860
+ background: 'var(--material-thin)',
861
+ border: '1px solid var(--separator)',
862
+ color: 'var(--text-primary)',
863
+ fontSize: 'var(--text-subheadline)',
864
+ lineHeight: 'var(--leading-relaxed)',
865
+ }}>
866
+ {formatMessage(textContent)}
867
+ {/* Streaming cursor */}
868
+ {isLastAssistant && textContent && (
869
+ <span style={{
870
+ display: 'inline-block',
871
+ width: 2,
872
+ height: '1.1em',
873
+ background: 'var(--accent)',
874
+ marginLeft: 2,
875
+ animation: 'blink-cursor 1s step-end infinite',
876
+ verticalAlign: 'text-bottom',
877
+ }} />
878
+ )}
879
+ {/* TTS listen button */}
880
+ {!isLastAssistant && textContent && (
881
+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 6 }}>
882
+ <button
883
+ onClick={() => playTts(msg.id, textContent)}
884
+ disabled={ttsLoadingId === msg.id}
885
+ title={ttsPlayingId === msg.id ? 'Stop listening' : 'Listen'}
886
+ style={{
887
+ background: 'none',
888
+ border: 'none',
889
+ cursor: ttsLoadingId === msg.id ? 'wait' : 'pointer',
890
+ padding: '2px 4px',
891
+ borderRadius: 'var(--radius-sm)',
892
+ color: ttsPlayingId === msg.id ? 'var(--accent)' : 'var(--text-tertiary)',
893
+ opacity: ttsLoadingId === msg.id ? 0.6 : 0.7,
894
+ transition: 'all 150ms ease',
895
+ display: 'flex',
896
+ alignItems: 'center',
897
+ gap: 4,
898
+ animation: ttsLoadingId === msg.id ? 'pulse-red 1.5s ease-in-out infinite' : 'none',
899
+ }}
900
+ onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
901
+ onMouseLeave={e => { e.currentTarget.style.opacity = ttsPlayingId === msg.id ? '1' : '0.7' }}
902
+ >
903
+ {ttsPlayingId === msg.id ? speakerStopIcon : speakerPlayIcon}
904
+ </button>
905
+ </div>
906
+ )}
907
+ </div>
908
+ )}
909
+
910
+ {/* Media attachments */}
911
+ {media.length > 0 && renderMedia(media, false)}
912
+ </div>
913
+ </div>
914
+ )}
915
+ </div>
916
+ )
917
+ })}
918
+ <div ref={bottomRef} />
919
+ </div>
920
+
921
+ {/* ── Input Area ────────────────────────────── */}
922
+ <div style={{
923
+ padding: 'var(--space-3) var(--space-4)',
924
+ borderTop: '1px solid var(--separator)',
925
+ background: 'var(--material-regular)',
926
+ flexShrink: 0,
927
+ }}>
928
+ {/* Pending attachments preview */}
929
+ {pendingAttachments.length > 0 && (
930
+ <div style={{ marginBottom: 'var(--space-2)' }}>
931
+ <MediaPreview
932
+ attachments={pendingAttachments}
933
+ onRemove={removePendingAttachment}
934
+ />
935
+ </div>
936
+ )}
937
+
938
+ <div style={{
939
+ display: 'flex',
940
+ alignItems: 'flex-end',
941
+ gap: 'var(--space-2)',
942
+ background: 'var(--fill-secondary)',
943
+ borderRadius: 'var(--radius-lg)',
944
+ padding: 'var(--space-2) var(--space-3)',
945
+ border: '1px solid var(--separator)',
946
+ }}>
947
+ {/* Attach button */}
948
+ <button
949
+ className="btn-ghost focus-ring"
950
+ aria-label="Attach file"
951
+ onClick={() => fileInputRef.current?.click()}
952
+ style={{
953
+ padding: 'var(--space-1)',
954
+ flexShrink: 0,
955
+ borderRadius: 'var(--radius-sm)',
956
+ display: 'flex',
957
+ alignItems: 'center',
958
+ justifyContent: 'center',
959
+ }}
960
+ >
961
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
962
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
963
+ </svg>
964
+ </button>
965
+ <input
966
+ ref={fileInputRef}
967
+ type="file"
968
+ accept="image/*,audio/*,.pdf,.doc,.docx,.txt,.csv,.json,.zip"
969
+ multiple
970
+ style={{ display: 'none' }}
971
+ onChange={handleFileAttach}
972
+ />
973
+
974
+ {/* Textarea */}
975
+ <textarea
976
+ ref={textareaRef}
977
+ value={input}
978
+ onChange={e => setInput(e.target.value)}
979
+ onKeyDown={handleKeyDown}
980
+ onPaste={handlePaste}
981
+ placeholder={`Message ${agent.name}...`}
982
+ rows={1}
983
+ disabled={isStreaming}
984
+ style={{
985
+ flex: 1,
986
+ background: 'transparent',
987
+ border: 'none',
988
+ outline: 'none',
989
+ resize: 'none',
990
+ color: 'var(--text-primary)',
991
+ fontSize: 'var(--text-subheadline)',
992
+ lineHeight: 'var(--leading-normal)',
993
+ maxHeight: 120,
994
+ minHeight: 24,
995
+ padding: '2px 0',
996
+ opacity: isStreaming ? 0.5 : 1,
997
+ }}
998
+ onInput={e => {
999
+ const target = e.target as HTMLTextAreaElement
1000
+ target.style.height = 'auto'
1001
+ target.style.height = Math.min(target.scrollHeight, 120) + 'px'
1002
+ }}
1003
+ />
1004
+
1005
+ {/* Send button */}
1006
+ <button
1007
+ className="focus-ring"
1008
+ onClick={() => sendMessage()}
1009
+ disabled={!hasContent || isStreaming}
1010
+ aria-label="Send message"
1011
+ style={{
1012
+ width: 32,
1013
+ height: 32,
1014
+ borderRadius: '50%',
1015
+ background: hasContent ? 'var(--accent)' : 'var(--fill-tertiary)',
1016
+ color: hasContent ? '#000' : 'var(--text-quaternary)',
1017
+ border: 'none',
1018
+ cursor: hasContent ? 'pointer' : 'default',
1019
+ display: 'flex',
1020
+ alignItems: 'center',
1021
+ justifyContent: 'center',
1022
+ fontSize: 16,
1023
+ fontWeight: 'var(--weight-bold)',
1024
+ transition: 'all 150ms var(--ease-smooth)',
1025
+ flexShrink: 0,
1026
+ }}
1027
+ >
1028
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1029
+ <line x1="12" y1="19" x2="12" y2="5" />
1030
+ <polyline points="5 12 12 5 19 12" />
1031
+ </svg>
1032
+ </button>
1033
+ </div>
1034
+
1035
+ {/* Hint */}
1036
+ <div style={{
1037
+ fontSize: 'var(--text-caption2)',
1038
+ color: 'var(--text-quaternary)',
1039
+ textAlign: 'center',
1040
+ marginTop: 'var(--space-1)',
1041
+ }}>
1042
+ Enter to send &middot; Shift+Enter for newline
1043
+ </div>
1044
+ </div>
1045
+ </div>
1046
+ )
1047
+ }