@pheem49/mint 1.8.2 → 1.10.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 (106) hide show
  1. package/Cargo.lock +55 -3
  2. package/Cargo.toml +2 -1
  3. package/Release_Note.md +297 -107
  4. package/bin/mint +0 -0
  5. package/crates/mint-cli/Cargo.toml +1 -0
  6. package/crates/mint-cli/src/actions.rs +233 -0
  7. package/crates/mint-cli/src/agent.rs +382 -127
  8. package/crates/mint-cli/src/gmail.rs +22 -2
  9. package/crates/mint-cli/src/image.rs +8 -8
  10. package/crates/mint-cli/src/interactive.rs +2888 -0
  11. package/crates/mint-cli/src/main.rs +391 -2506
  12. package/crates/mint-cli/src/markdown.rs +294 -0
  13. package/crates/mint-cli/src/onboard.rs +152 -109
  14. package/crates/mint-cli/src/setup.rs +143 -143
  15. package/crates/mint-cli/src/updater.rs +48 -6
  16. package/crates/mint-core/Cargo.toml +1 -0
  17. package/crates/mint-core/src/agent_loop.rs +1 -0
  18. package/crates/mint-core/src/api_server.rs +431 -65
  19. package/crates/mint-core/src/browser.rs +630 -102
  20. package/crates/mint-core/src/channels.rs +105 -6
  21. package/crates/mint-core/src/chat.rs +174 -11
  22. package/crates/mint-core/src/code_tools.rs +47 -31
  23. package/crates/mint-core/src/config.rs +302 -17
  24. package/crates/mint-core/src/image_gen.rs +299 -38
  25. package/crates/mint-core/src/lib.rs +10 -7
  26. package/crates/mint-core/src/mcp.rs +119 -6
  27. package/crates/mint-core/src/memory.rs +6 -1
  28. package/crates/mint-core/src/orchestration.rs +237 -96
  29. package/crates/mint-core/src/pictures.rs +67 -5
  30. package/crates/mint-core/src/semantic.rs +1 -1
  31. package/crates/mint-core/src/shell.rs +1 -1
  32. package/crates/mint-core/src/skills.rs +128 -25
  33. package/crates/mint-core/src/video_gen.rs +235 -0
  34. package/package.json +8 -2
  35. package/shared-modules/linux-audio/fluidsynth2-static.json +9 -3
  36. package/shared-modules/linux-audio/fluidsynth2.json +8 -2
  37. package/shared-modules/linux-audio/lilv.json +26 -19
  38. package/shared-modules/linux-audio/lv2.json +0 -1
  39. package/shared-modules/luajit/luajit.json +2 -2
  40. package/skills-lock.json +41 -0
  41. package/src/renderer/index-web.html +1 -1
  42. package/src/renderer/index.html +1 -1
  43. package/src/renderer/shared/components/AgentActivityTable.tsx +25 -6
  44. package/src/renderer/shared/constants/slashCommands.ts +27 -0
  45. package/src/renderer/shared/platform.ts +3 -0
  46. package/src/renderer/shared/types.ts +4 -0
  47. package/src/renderer/shared/utils/approval.tsx +86 -7
  48. package/src/renderer/shared/utils/fileIcons.ts +135 -0
  49. package/src/renderer/shared/utils/markdown.tsx +217 -88
  50. package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
  51. package/src/renderer/src/components/ChatPanel.tsx +486 -69
  52. package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
  53. package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
  54. package/src/renderer/src/components/MintDashboard.tsx +195 -7
  55. package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
  56. package/src/renderer/src/components/Settings/AgentsTab.tsx +21 -2
  57. package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
  58. package/src/renderer/src/components/Settings/GeneralTab.tsx +795 -413
  59. package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
  60. package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
  61. package/src/renderer/src/components/SettingsWindow.tsx +106 -3
  62. package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
  63. package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
  64. package/src/renderer/src/css/image-studio.css +106 -0
  65. package/src/renderer/src/css/settings.css +158 -0
  66. package/src/renderer/src/css/styles.css +368 -1
  67. package/src/renderer/src/css/veo-studio.css +626 -0
  68. package/src/renderer/src/index.css +1 -0
  69. package/src/renderer/src/tauri.ts +144 -17
  70. package/src/renderer/src-web/components/ChatPanel.tsx +385 -93
  71. package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
  72. package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
  73. package/src/renderer/src-web/components/MintDashboard.tsx +156 -5
  74. package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
  75. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +21 -2
  76. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
  77. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +795 -411
  78. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
  79. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
  80. package/src/renderer/src-web/components/SettingsWindow.tsx +110 -3
  81. package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
  82. package/src/renderer/src-web/css/image-studio.css +106 -0
  83. package/src/renderer/src-web/css/settings.css +158 -0
  84. package/src/renderer/src-web/css/styles.css +369 -1
  85. package/src/renderer/src-web/css/veo-studio.css +650 -0
  86. package/src/renderer/src-web/index.css +1 -0
  87. package/src/renderer/src-web/tauri.ts +135 -16
  88. package/src-tauri/src/desktop.rs +18 -16
  89. package/src-tauri/src/discord_rpc.rs +1 -1
  90. package/src-tauri/src/lib.rs +155 -25
  91. package/src-tauri/src/webhooks.rs +45 -31
  92. package/src-tauri/src/workflows.rs +1 -0
  93. package/src-tauri/tauri.conf.json +1 -1
  94. package/temp_gridgeist/AGENTS.md +8 -0
  95. package/temp_gridgeist/CHANGELOG.md +130 -0
  96. package/temp_gridgeist/LICENSE +21 -0
  97. package/temp_gridgeist/README.md +323 -0
  98. package/temp_gridgeist/README.th.md +325 -0
  99. package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
  100. package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
  101. package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
  102. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
  103. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
  104. package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
  105. package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
  106. package/temp_gridgeist/skills/gridgeist/references/system-contract.md +109 -0
@@ -12,11 +12,12 @@ import {
12
12
  } from '../../shared/constants/models'
13
13
  import { badge, providerLabel, fallbackNotice } from '../../shared/utils/providers'
14
14
  import { activitiesFrom, parseWebSearchSources, type AgentActivity, type AgentActivityView } from '../../shared/utils/agentActivity'
15
+ import { SLASH_COMMANDS } from '../../shared/constants/slashCommands'
15
16
  import { AgentActivityTable } from '../../shared/components/AgentActivityTable'
16
17
  import { ChatCodeBlock } from '../../shared/components/ChatCodeBlock'
17
18
  import { renderApprovalDetails, renderDiff, type ApprovalDetails } from '../../shared/utils/approval'
18
19
  import { ApprovalCard } from '../../shared/components/ApprovalCard'
19
- import { renderFormattedMessage, readableAssistantText, cleanSpeechText, renderSpeakerIcon } from '../../shared/utils/markdown'
20
+ import { renderFormattedMessage, readableAssistantText, cleanSpeechText, renderSpeakerIcon, renderCopyIcon } from '../../shared/utils/markdown'
20
21
  import { ThinkingBlock } from '../../shared/components/ThinkingBlock'
21
22
  import { AgentActivityDrawer } from '../../shared/components/AgentActivityDrawer'
22
23
  import type { DiffHunk, FileChange } from '../../shared/types'
@@ -26,6 +27,8 @@ import { useSpeechToText } from '../../shared/utils/speech'
26
27
 
27
28
 
28
29
  import {
30
+ listLearnedSkills,
31
+ type LearnedSkill,
29
32
  type AgentProgress,
30
33
  type ChatResponse,
31
34
  type RuntimeStatus,
@@ -38,6 +41,7 @@ interface ChatPanelProps {
38
41
  sending: boolean
39
42
  sendingMessage: string
40
43
  sendingImageCount: number
44
+ sendingVideoCount?: number
41
45
  streamedReply: string
42
46
  streamedResponse: ChatResponse | null
43
47
  agentProgress: AgentProgress[]
@@ -46,6 +50,7 @@ interface ChatPanelProps {
46
50
  onThinkingExpandedChange: (key: string, open: boolean) => void
47
51
  message: string
48
52
  imageAttachments: Array<{ dataUri: string; name: string; previewDataUri?: string }>
53
+ videoAttachments: Array<{ dataUri: string; name: string }>
49
54
  documentName: string
50
55
  pendingApproval: any | null
51
56
  smartContext: boolean
@@ -56,11 +61,13 @@ interface ChatPanelProps {
56
61
  welcomeInteraction: any
57
62
  onSubmit: (event: FormEvent<HTMLFormElement>) => void
58
63
  onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
64
+ onSelectVideo: (event: ChangeEvent<HTMLInputElement>) => void
59
65
  onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
60
66
  onPasteImage: (clipboardData: DataTransfer) => boolean
61
67
  onSetMessage: (message: string) => void
62
68
  onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
63
69
  onRemoveImage: (idx: number) => void
70
+ onRemoveVideo: (idx: number) => void
64
71
  onRemoveDocument: () => void
65
72
  onStartWebSearch: () => void
66
73
  onCaptureScreen: () => void
@@ -80,6 +87,7 @@ export default function ChatPanel({
80
87
  sending,
81
88
  sendingMessage,
82
89
  sendingImageCount,
90
+ sendingVideoCount,
83
91
  streamedReply,
84
92
  streamedResponse,
85
93
  agentProgress,
@@ -88,6 +96,7 @@ export default function ChatPanel({
88
96
  onThinkingExpandedChange,
89
97
  message,
90
98
  imageAttachments,
99
+ videoAttachments,
91
100
  documentName,
92
101
  pendingApproval,
93
102
  smartContext,
@@ -98,11 +107,13 @@ export default function ChatPanel({
98
107
  welcomeInteraction,
99
108
  onSubmit,
100
109
  onSelectImage,
110
+ onSelectVideo,
101
111
  onSelectDocument,
102
112
  onPasteImage,
103
113
  onSetMessage,
104
114
  onSendVoiceMessage,
105
115
  onRemoveImage,
116
+ onRemoveVideo,
106
117
  onRemoveDocument,
107
118
  onStartWebSearch,
108
119
  onCaptureScreen,
@@ -170,6 +181,22 @@ export default function ChatPanel({
170
181
  return () => clearInterval(timer)
171
182
  }, [sending])
172
183
  const [speakingText, setSpeakingText] = useState<string | null>(null)
184
+ const [copiedId, setCopiedId] = useState<string | number | null>(null)
185
+
186
+ const handleCopyMessage = async (id: string | number, text: string) => {
187
+ try {
188
+ const cleanText = readableAssistantText(text) || text
189
+ await navigator.clipboard.writeText(cleanText)
190
+ setCopiedId(id)
191
+ setTimeout(() => {
192
+ setCopiedId((current) => (current === id ? null : current))
193
+ }, 2000)
194
+ } catch (err) {
195
+ console.error('Failed to copy message:', err)
196
+ }
197
+ }
198
+
199
+
173
200
  const toolMenuRef = useRef<HTMLDivElement | null>(null)
174
201
  const audioRef = useRef<HTMLAudioElement | null>(null)
175
202
  const speechRunRef = useRef(0)
@@ -178,6 +205,72 @@ export default function ChatPanel({
178
205
  const lastAutoSpokenIdRef = useRef<number | string | null>(null)
179
206
  const speakingRef = useRef<string | null>(null)
180
207
 
208
+ // Drag and Drop Zone Overlay
209
+ const [isDragging, setIsDragging] = useState(false)
210
+ const dragCounter = useRef(0)
211
+
212
+ const handleDragEnter = (e: DragEvent<HTMLElement>) => {
213
+ e.preventDefault()
214
+ if (e.dataTransfer?.types?.includes('Files')) {
215
+ dragCounter.current++
216
+ setIsDragging(true)
217
+ }
218
+ }
219
+
220
+ const handleDragLeave = (e: DragEvent<HTMLElement>) => {
221
+ e.preventDefault()
222
+ if (e.dataTransfer?.types?.includes('Files')) {
223
+ dragCounter.current--
224
+ if (dragCounter.current === 0) {
225
+ setIsDragging(false)
226
+ }
227
+ }
228
+ }
229
+
230
+ const handleDragOver = (e: DragEvent<HTMLElement>) => {
231
+ e.preventDefault()
232
+ }
233
+
234
+ const handleDrop = (e: DragEvent<HTMLElement>) => {
235
+ e.preventDefault()
236
+ dragCounter.current = 0
237
+ setIsDragging(false)
238
+
239
+ const files = e.dataTransfer?.files
240
+ if (files && files.length > 0) {
241
+ const file = files[0]
242
+ if (file.type.startsWith('image/')) {
243
+ const input = document.getElementById('vision-file-input') as HTMLInputElement | null
244
+ if (input) {
245
+ const dt = new DataTransfer()
246
+ dt.items.add(file)
247
+ input.files = dt.files
248
+ const event = { target: input } as ChangeEvent<HTMLInputElement>
249
+ onSelectImage(event)
250
+ }
251
+ } else if (file.type.startsWith('video/')) {
252
+ const input = document.getElementById('video-file-input') as HTMLInputElement | null
253
+ if (input) {
254
+ const dt = new DataTransfer()
255
+ dt.items.add(file)
256
+ input.files = dt.files
257
+ const event = { target: input } as ChangeEvent<HTMLInputElement>
258
+ onSelectVideo(event)
259
+ }
260
+ } else if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
261
+ const input = document.getElementById('document-file-input') as HTMLInputElement | null
262
+ if (input) {
263
+ const dt = new DataTransfer()
264
+ dt.items.add(file)
265
+ input.files = dt.files
266
+ const event = { target: input } as ChangeEvent<HTMLInputElement>
267
+ onSelectDocument(event)
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+
181
274
  const {
182
275
  isRecording,
183
276
  voiceMode,
@@ -200,8 +293,9 @@ export default function ChatPanel({
200
293
  onSetMessage: (val) => onSetMessage(val)
201
294
  })
202
295
 
203
- const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
296
+ const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || videoAttachments.length > 0 || documentName)
204
297
  const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
298
+ const sendingVideoMarkers = Array.from({ length: sendingVideoCount || 0 }, (_, index) => `[Video #${index + 1}]`).join(' ')
205
299
  const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
206
300
  const voiceStatusLabel = voiceStatus === 'speaking' ? 'Speaking' : voiceStatus === 'thinking' ? 'Thinking' : voiceStatus === 'listening' ? 'Listening' : 'Ready'
207
301
 
@@ -224,8 +318,13 @@ export default function ChatPanel({
224
318
  case 'ollama':
225
319
  return dynamicOllamaModels
226
320
  default:
321
+ if (provider.startsWith('custom:')) {
322
+ const id = provider.replace(/^custom:/, '')
323
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
324
+ return cp?.models.map(m => m.modelId) ?? []
325
+ }
227
326
  return []
228
- }
327
+ }
229
328
  }
230
329
 
231
330
  const activeProvider = status?.activeProvider ?? ''
@@ -251,6 +350,11 @@ export default function ChatPanel({
251
350
  case 'ollama':
252
351
  return settingsConfig.ollamaModel
253
352
  default:
353
+ if (provider.startsWith('custom:')) {
354
+ const id = provider.replace(/^custom:/, '')
355
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
356
+ return (settingsConfig.customModelSelections ?? {})[id] ?? cp?.models[0]?.modelId ?? ''
357
+ }
254
358
  return ''
255
359
  }
256
360
  }
@@ -258,12 +362,154 @@ export default function ChatPanel({
258
362
  const workspaceName = workspacePath
259
363
  ? workspacePath.split(/[\\/]/).filter(Boolean).pop() || workspacePath
260
364
  : 'Select Project'
365
+ const [skillsList, setSkillsList] = useState<LearnedSkill[]>([])
366
+ const [slashMenuOpen, setSlashMenuOpen] = useState(true)
367
+ const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
368
+ const slashMenuRef = useRef<HTMLDivElement>(null)
369
+ const slashListRef = useRef<HTMLDivElement>(null)
370
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
371
+
372
+ const isSlashInput = message.trimStart().startsWith('/')
373
+ const isSkillInput = message.trimStart().startsWith('$')
374
+ const atMatch = message.match(/@([\w\-\.\/]*)$/)
375
+ const isAtInput = Boolean(atMatch)
376
+ const atQuery = atMatch ? atMatch[1].toLowerCase() : ''
377
+
378
+ const CONTEXT_SUGGESTIONS = [
379
+ { label: '@workspace', desc: 'Include workspace path & context' },
380
+ { label: '@file', desc: 'Reference workspace file' },
381
+ { label: '@docs', desc: 'Include documentation context' },
382
+ { label: '@memory', desc: 'Include long-term memory store' },
383
+ ]
384
+
385
+ const filteredContexts = isAtInput
386
+ ? CONTEXT_SUGGESTIONS.filter((item) => item.label.toLowerCase().includes(atQuery))
387
+ : []
388
+
389
+ useEffect(() => {
390
+ let isMounted = true
391
+ listLearnedSkills(workspacePath)
392
+ .then((res) => {
393
+ if (isMounted && Array.isArray(res)) setSkillsList(res)
394
+ })
395
+ .catch(() => {})
396
+ return () => { isMounted = false }
397
+ }, [workspacePath, isSkillInput])
398
+
399
+ const slashQuery = isSlashInput ? message.trimStart().toLowerCase() : ''
400
+ const filteredSlashCommands = isSlashInput
401
+ ? SLASH_COMMANDS.filter((c) => c.command.toLowerCase().startsWith(slashQuery))
402
+ : []
403
+
404
+ const skillQuery = isSkillInput ? message.trimStart().slice(1).toLowerCase() : ''
405
+ const filteredSkills = isSkillInput
406
+ ? skillsList.filter((s) => s.name.toLowerCase().startsWith(skillQuery))
407
+ : []
408
+
409
+ const showSuggestionMenu = slashMenuOpen && (
410
+ (isSlashInput && filteredSlashCommands.length > 0) ||
411
+ (isSkillInput && filteredSkills.length > 0) ||
412
+ (isAtInput && filteredContexts.length > 0)
413
+ )
414
+
415
+ const suggestionCount = isSlashInput
416
+ ? filteredSlashCommands.length
417
+ : isSkillInput
418
+ ? filteredSkills.length
419
+ : filteredContexts.length
420
+
421
+ useEffect(() => {
422
+ if (isSlashInput || isSkillInput || isAtInput) {
423
+ setSlashMenuOpen(true)
424
+ setSlashSelectedIndex(0)
425
+ }
426
+ }, [message])
427
+
428
+ useEffect(() => {
429
+ if (showSuggestionMenu && slashListRef.current) {
430
+ const activeItem = slashListRef.current.children[slashSelectedIndex] as HTMLElement
431
+ if (activeItem) {
432
+ activeItem.scrollIntoView({ block: 'nearest' })
433
+ }
434
+ }
435
+ }, [slashSelectedIndex, showSuggestionMenu])
436
+
437
+ useEffect(() => {
438
+ const handleClickOutside = (event: MouseEvent) => {
439
+ if (
440
+ slashMenuRef.current &&
441
+ !slashMenuRef.current.contains(event.target as Node) &&
442
+ textareaRef.current &&
443
+ !textareaRef.current.contains(event.target as Node)
444
+ ) {
445
+ setSlashMenuOpen(false)
446
+ }
447
+ }
448
+ document.addEventListener('mousedown', handleClickOutside)
449
+ return () => document.removeEventListener('mousedown', handleClickOutside)
450
+ }, [])
451
+
452
+ const selectSlashCommand = (cmd: string) => {
453
+ onSetMessage(cmd + ' ')
454
+ setSlashMenuOpen(false)
455
+ setSlashSelectedIndex(0)
456
+ setTimeout(() => textareaRef.current?.focus(), 0)
457
+ }
458
+
459
+ const selectSkillCommand = (name: string) => {
460
+ onSetMessage('$' + name + ' ')
461
+ setSlashMenuOpen(false)
462
+ setSlashSelectedIndex(0)
463
+ setTimeout(() => textareaRef.current?.focus(), 0)
464
+ }
465
+
466
+ const selectAtCommand = (label: string) => {
467
+ const nextMsg = message.replace(/@[\w\-\.\/]*$/, label + ' ')
468
+ onSetMessage(nextMsg)
469
+ setSlashMenuOpen(false)
470
+ setSlashSelectedIndex(0)
471
+ setTimeout(() => textareaRef.current?.focus(), 0)
472
+ }
473
+
261
474
  const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
262
475
  if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
263
476
  event.preventDefault()
264
477
  event.currentTarget.form?.requestSubmit()
265
478
  }
266
479
  const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
480
+ if (showSuggestionMenu) {
481
+ if (event.key === 'ArrowDown') {
482
+ event.preventDefault()
483
+ setSlashSelectedIndex((prev) => (prev + 1) % suggestionCount)
484
+ return
485
+ }
486
+ if (event.key === 'ArrowUp') {
487
+ event.preventDefault()
488
+ setSlashSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount)
489
+ return
490
+ }
491
+ if (event.key === 'Enter' || event.key === 'Tab') {
492
+ if (!event.shiftKey && !event.nativeEvent.isComposing) {
493
+ event.preventDefault()
494
+ if (isSlashInput) {
495
+ const selected = filteredSlashCommands[slashSelectedIndex] || filteredSlashCommands[0]
496
+ if (selected) selectSlashCommand(selected.command)
497
+ } else if (isSkillInput) {
498
+ const selected = filteredSkills[slashSelectedIndex] || filteredSkills[0]
499
+ if (selected) selectSkillCommand(selected.name)
500
+ } else if (isAtInput) {
501
+ const selected = filteredContexts[slashSelectedIndex] || filteredContexts[0]
502
+ if (selected) selectAtCommand(selected.label)
503
+ }
504
+ return
505
+ }
506
+ }
507
+ if (event.key === 'Escape') {
508
+ event.preventDefault()
509
+ setSlashMenuOpen(false)
510
+ return
511
+ }
512
+ }
267
513
  submitOnEnter(event)
268
514
  }
269
515
 
@@ -382,14 +628,19 @@ export default function ChatPanel({
382
628
  }
383
629
  }
384
630
  if (sending) return
385
- if (!settingsConfig?.enableVoiceReply && !voiceMode) {
386
- lastAutoSpokenIdRef.current = latest?.id ?? null
631
+ if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
632
+
633
+ if (!settingsConfig?.enableVoiceReply) {
634
+ lastAutoSpokenIdRef.current = latest.id
635
+ if (voiceMode) {
636
+ scheduleVoiceListen(350)
637
+ }
387
638
  return
388
639
  }
389
- if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
640
+
390
641
  lastAutoSpokenIdRef.current = latest.id
391
642
  speak(latest.aiText)
392
- }, [interactions, sending, settingsConfig?.enableVoiceReply])
643
+ }, [interactions, sending, settingsConfig?.enableVoiceReply, voiceMode])
393
644
  useEffect(() => {
394
645
  if (!toolMenuOpen) return
395
646
  const closeMenu = (event: MouseEvent) => {
@@ -429,6 +680,10 @@ export default function ChatPanel({
429
680
  setToolMenuOpen(false)
430
681
  document.getElementById('vision-file-input')?.click()
431
682
  }
683
+ const openVideoPicker = () => {
684
+ setToolMenuOpen(false)
685
+ document.getElementById('video-file-input')?.click()
686
+ }
432
687
  const openDocumentPicker = () => {
433
688
  setToolMenuOpen(false)
434
689
  document.getElementById('document-file-input')?.click()
@@ -715,7 +970,46 @@ export default function ChatPanel({
715
970
  }
716
971
 
717
972
  return (
718
- <section className={`conversation-panel ${isEmptyChat ? 'is-empty' : ''}`}>
973
+ <section
974
+ className={`conversation-panel ${isEmptyChat ? 'is-empty' : ''}`}
975
+ onDragEnter={handleDragEnter}
976
+ onDragOver={handleDragOver}
977
+ onDragLeave={handleDragLeave}
978
+ onDrop={handleDrop}
979
+ style={{ position: 'relative' }}
980
+ >
981
+ {isDragging && (
982
+ <div
983
+ className="drag-drop-overlay"
984
+ onDragEnter={handleDragEnter}
985
+ onDragLeave={handleDragLeave}
986
+ onDragOver={handleDragOver}
987
+ onDrop={handleDrop}
988
+ style={{
989
+ position: 'absolute',
990
+ top: 0,
991
+ left: 0,
992
+ right: 0,
993
+ bottom: 0,
994
+ background: 'rgba(15, 23, 42, 0.82)',
995
+ backdropFilter: 'blur(8px)',
996
+ border: '2px dashed var(--accent)',
997
+ borderRadius: '16px',
998
+ margin: '12px',
999
+ display: 'flex',
1000
+ flexDirection: 'column',
1001
+ alignItems: 'center',
1002
+ justifyContent: 'center',
1003
+ color: 'white',
1004
+ zIndex: 1000,
1005
+ pointerEvents: 'auto',
1006
+ }}
1007
+ >
1008
+ <div style={{ fontSize: '3.5rem', marginBottom: '16px' }}>🖼️</div>
1009
+ <div style={{ fontSize: '1.25rem', fontWeight: 'bold', letterSpacing: '0.5px' }}>Drag files to attach data</div>
1010
+ <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>Supports images (PNG, JPEG, WebP, GIF), videos (MP4, WebM, MOV, MKV), and PDF files</div>
1011
+ </div>
1012
+ )}
719
1013
  <div className="chat-header">
720
1014
  <div className="chat-header-title">
721
1015
  <img src="./assets/icon.png" alt="Logo" className="chat-header-logo" />
@@ -726,6 +1020,8 @@ export default function ChatPanel({
726
1020
  {interactions.map((interaction) => {
727
1021
  const isSystemEvent = interaction.provider === 'system' && interaction.model === 'provider_change';
728
1022
  if (isSystemEvent) {
1023
+ const rawText = interaction.userText || ''
1024
+ const cleanText = rawText.replace(/^Changed model to\s*/i, '').trim()
729
1025
  return (
730
1026
  <div key={interaction.id} className="system-event-divider">
731
1027
  <div className="system-event-line" />
@@ -733,7 +1029,7 @@ export default function ChatPanel({
733
1029
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
734
1030
  <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
735
1031
  </svg>
736
- <span>{interaction.userText}</span>
1032
+ <span>{cleanText}</span>
737
1033
  </div>
738
1034
  <div className="system-event-line" />
739
1035
  </div>
@@ -745,7 +1041,17 @@ export default function ChatPanel({
745
1041
  <div className="message user-message">
746
1042
  <div className="bubble-wrapper">
747
1043
  <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
748
- <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1044
+ <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1045
+ <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
1046
+ <button
1047
+ type="button"
1048
+ className={`msg-action-btn copy-btn ${copiedId === `user-${interaction.id}` ? 'is-copied' : ''}`}
1049
+ onClick={() => handleCopyMessage(`user-${interaction.id}`, interaction.userText)}
1050
+ title={copiedId === `user-${interaction.id}` ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy)'}
1051
+ >
1052
+ {renderCopyIcon(copiedId === `user-${interaction.id}`)}
1053
+ </button>
1054
+ </div>
749
1055
  </div>
750
1056
  </div>
751
1057
  )}
@@ -766,20 +1072,30 @@ export default function ChatPanel({
766
1072
  />
767
1073
  )
768
1074
  })()}
769
- <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1075
+ <div className="message-bubble">{renderFormattedMessage(interaction.aiText)}</div>
770
1076
  {renderWebSearchSources(interaction)}
771
1077
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
772
1078
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
773
1079
  {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
774
1080
  <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
775
- <button
776
- type="button"
777
- className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
778
- onClick={() => speak(interaction.aiText)}
779
- title={speakingText === interaction.aiText ? 'Stop reading' : 'Read aloud'}
780
- >
781
- {renderSpeakerIcon(speakingText === interaction.aiText)}
782
- </button>
1081
+ <div className="message-action-buttons" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto' }}>
1082
+ <button
1083
+ type="button"
1084
+ className={`msg-action-btn copy-btn ${copiedId === interaction.id ? 'is-copied' : ''}`}
1085
+ onClick={() => handleCopyMessage(interaction.id, interaction.aiText)}
1086
+ title={copiedId === interaction.id ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy message)'}
1087
+ >
1088
+ {renderCopyIcon(copiedId === interaction.id)}
1089
+ </button>
1090
+ <button
1091
+ type="button"
1092
+ className={`msg-action-btn tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
1093
+ onClick={() => speak(interaction.aiText)}
1094
+ title={speakingText === interaction.aiText ? 'Stop reading' : 'Read aloud'}
1095
+ >
1096
+ {renderSpeakerIcon(speakingText === interaction.aiText)}
1097
+ </button>
1098
+ </div>
783
1099
  </div>
784
1100
  </div>
785
1101
  </div>
@@ -789,7 +1105,7 @@ export default function ChatPanel({
789
1105
 
790
1106
  {sending && (
791
1107
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
792
- <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
1108
+ <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{renderFormattedMessage([sendingMessage, sendingImageMarkers, sendingVideoMarkers].filter(Boolean).join(' '))}</div></div></div>
793
1109
  {agentMode && (
794
1110
  <AgentActivityDrawer
795
1111
  activityView={agentActivities}
@@ -848,14 +1164,24 @@ export default function ChatPanel({
848
1164
  <button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
849
1165
  {activeFallbackNotice && <span className="provider-fallback-notice">{activeFallbackNotice}</span>}
850
1166
  {streamedReply && (
851
- <button
852
- type="button"
853
- className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
854
- onClick={() => speak(streamedReply)}
855
- title={speakingText === streamedReply ? 'Stop reading' : 'Read aloud'}
856
- >
857
- {renderSpeakerIcon(speakingText === streamedReply)}
858
- </button>
1167
+ <div className="message-action-buttons" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto' }}>
1168
+ <button
1169
+ type="button"
1170
+ className={`msg-action-btn copy-btn ${copiedId === 'live' ? 'is-copied' : ''}`}
1171
+ onClick={() => handleCopyMessage('live', streamedReply)}
1172
+ title={copiedId === 'live' ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy message)'}
1173
+ >
1174
+ {renderCopyIcon(copiedId === 'live')}
1175
+ </button>
1176
+ <button
1177
+ type="button"
1178
+ className={`msg-action-btn tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
1179
+ onClick={() => speak(streamedReply)}
1180
+ title={speakingText === streamedReply ? 'Stop reading' : 'Read aloud'}
1181
+ >
1182
+ {renderSpeakerIcon(speakingText === streamedReply)}
1183
+ </button>
1184
+ </div>
859
1185
  )}
860
1186
  </div>
861
1187
  )}
@@ -922,6 +1248,65 @@ export default function ChatPanel({
922
1248
  </div>
923
1249
  )}
924
1250
 
1251
+ {showSuggestionMenu && (
1252
+ <div className="slash-suggestions-popup" ref={slashMenuRef}>
1253
+ <div className="slash-suggestions-header">
1254
+ {isSlashInput ? 'Slash Commands' : isSkillInput ? 'Learned Skills' : 'Context Mentions'} ({slashSelectedIndex + 1}/{suggestionCount})
1255
+ </div>
1256
+ <div className="slash-suggestions-list" ref={slashListRef}>
1257
+ {isSlashInput
1258
+ ? filteredSlashCommands.map((item, idx) => (
1259
+ <button
1260
+ key={item.command}
1261
+ type="button"
1262
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1263
+ onMouseDown={(e) => {
1264
+ e.preventDefault()
1265
+ selectSlashCommand(item.command)
1266
+ }}
1267
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1268
+ >
1269
+ <span className="slash-cmd-name">{item.command}</span>
1270
+ <span className="slash-cmd-desc">{item.description}</span>
1271
+ </button>
1272
+ ))
1273
+ : isSkillInput
1274
+ ? filteredSkills.map((item, idx) => (
1275
+ <button
1276
+ key={item.id || item.name}
1277
+ type="button"
1278
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1279
+ onMouseDown={(e) => {
1280
+ e.preventDefault()
1281
+ selectSkillCommand(item.name)
1282
+ }}
1283
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1284
+ >
1285
+ <span className="slash-cmd-name">${item.name}</span>
1286
+ <span className="skill-badge">[Skill]</span>
1287
+ <span className="slash-cmd-desc">{item.description || item.content?.slice(0, 60)}</span>
1288
+ </button>
1289
+ ))
1290
+ : filteredContexts.map((item, idx) => (
1291
+ <button
1292
+ key={item.label}
1293
+ type="button"
1294
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1295
+ onMouseDown={(e) => {
1296
+ e.preventDefault()
1297
+ selectAtCommand(item.label)
1298
+ }}
1299
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1300
+ >
1301
+ <span className="slash-cmd-name">{item.label}</span>
1302
+ <span className="skill-badge">[Context]</span>
1303
+ <span className="slash-cmd-desc">{item.desc}</span>
1304
+ </button>
1305
+ ))}
1306
+ </div>
1307
+ </div>
1308
+ )}
1309
+
925
1310
  <form
926
1311
  id="chat-form"
927
1312
  onSubmit={onSubmit}
@@ -936,7 +1321,7 @@ export default function ChatPanel({
936
1321
  if (onPasteImage(event.clipboardData)) event.preventDefault()
937
1322
  }}
938
1323
  >
939
- {(imageAttachments.length > 0 || documentName) && (
1324
+ {(imageAttachments.length > 0 || videoAttachments.length > 0 || documentName) && (
940
1325
  <div className="mint-attachment">
941
1326
  {imageAttachments.map((attachment, idx) => (
942
1327
  <div className="mint-image-attachment" key={idx}>
@@ -949,6 +1334,22 @@ export default function ChatPanel({
949
1334
  </button>
950
1335
  </div>
951
1336
  ))}
1337
+ {videoAttachments.map((attachment, idx) => (
1338
+ <div className="mint-image-attachment" key={idx}>
1339
+ <video className="mint-image-preview" src={attachment.dataUri} muted playsInline preload="metadata" />
1340
+ <div className="mint-video-play-indicator" style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', pointerEvents: 'none', background: 'rgba(0,0,0,0.6)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '22px', height: '22px', border: '1.5px solid white' }}>
1341
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="white" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1342
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
1343
+ </svg>
1344
+ </div>
1345
+ <button className="mint-attachment-remove" type="button" onClick={() => onRemoveVideo(idx)} aria-label="Remove video">
1346
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1347
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1348
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1349
+ </svg>
1350
+ </button>
1351
+ </div>
1352
+ ))}
952
1353
  {documentName && (
953
1354
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
954
1355
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--text-soft)' }}>
@@ -959,7 +1360,7 @@ export default function ChatPanel({
959
1360
  </span>
960
1361
  <span style={{ fontSize: '0.76rem', color: 'var(--text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '220px' }}>{documentName}</span>
961
1362
  <button type="button" onClick={onRemoveDocument} style={{ background: 'transparent', border: 0, color: '#ef4444', cursor: 'pointer', display: 'inline-flex', alignItems: 'center' }}>
962
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1363
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
963
1364
  <line x1="18" y1="6" x2="6" y2="18"></line>
964
1365
  <line x1="6" y1="6" x2="18" y2="18"></line>
965
1366
  </svg>
@@ -970,6 +1371,7 @@ export default function ChatPanel({
970
1371
  )}
971
1372
  <textarea
972
1373
  id="chat-input"
1374
+ ref={textareaRef}
973
1375
  value={message}
974
1376
  onChange={(event) => {
975
1377
  resizeInput(event.currentTarget)
@@ -981,46 +1383,56 @@ export default function ChatPanel({
981
1383
  rows={1}
982
1384
  />
983
1385
  <div className="chat-tool-menu-wrap" ref={toolMenuRef}>
984
- <button id="chat-tool-btn" type="button" aria-haspopup="menu" aria-expanded={toolMenuOpen} onClick={() => setToolMenuOpen((open) => !open)} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
985
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
986
- <line x1="12" y1="5" x2="12" y2="19"></line>
987
- <line x1="5" y1="12" x2="19" y2="12"></line>
988
- </svg>
989
- </button>
990
- {toolMenuOpen && (
991
- <div className="chat-tool-menu" role="menu">
992
- <button type="button" role="menuitem" onClick={openImagePicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
993
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
994
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
995
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
996
- <circle cx="8.5" cy="8.5" r="1.5"></circle>
997
- <polyline points="21 15 16 10 5 21"></polyline>
998
- </svg>
999
- </span>
1000
- <span>Add image</span>
1001
- </button>
1002
- <button type="button" role="menuitem" onClick={openDocumentPicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1003
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1004
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1005
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1006
- <polyline points="14 2 14 8 20 8"></polyline>
1007
- </svg>
1008
- </span>
1009
- <span>Add file</span>
1010
- </button>
1011
- <button type="button" role="menuitem" onClick={startWebSearch} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1012
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1013
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1014
- <circle cx="11" cy="11" r="8"></circle>
1015
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
1016
- </svg>
1017
- </span>
1018
- <span>Search web</span>
1019
- </button>
1020
- </div>
1021
- )}
1022
- </div>
1386
+ <button id="chat-tool-btn" type="button" aria-haspopup="menu" aria-expanded={toolMenuOpen} onClick={() => setToolMenuOpen((open) => !open)} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
1387
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1388
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1389
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1390
+ </svg>
1391
+ </button>
1392
+ {toolMenuOpen && (
1393
+ <div className="chat-tool-menu" role="menu">
1394
+ <button type="button" role="menuitem" onClick={openImagePicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1395
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1396
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1397
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1398
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
1399
+ <polyline points="21 15 16 10 5 21"></polyline>
1400
+ </svg>
1401
+ </span>
1402
+ <span>Add image</span>
1403
+ </button>
1404
+ <button type="button" role="menuitem" onClick={openVideoPicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1405
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1406
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1407
+ <polygon points="23 7 16 12 23 17 23 7"></polygon>
1408
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
1409
+ </svg>
1410
+ </span>
1411
+ <span>Add video</span>
1412
+ </button>
1413
+ <button type="button" role="menuitem" onClick={openDocumentPicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1414
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1415
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1416
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1417
+ <polyline points="14 2 14 8 20 8"></polyline>
1418
+ </svg>
1419
+ </span>
1420
+ <span>Add file</span>
1421
+ </button>
1422
+ <button type="button" role="menuitem" onClick={startWebSearch} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1423
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1424
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1425
+ <circle cx="11" cy="11" r="8"></circle>
1426
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
1427
+ </svg>
1428
+ </span>
1429
+ <span>Search web</span>
1430
+ </button>
1431
+ </div>
1432
+ )}
1433
+ </div>
1023
1434
  <input id="vision-file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={onSelectImage} style={{ display: 'none' }} />
1435
+ <input id="video-file-input" type="file" accept="video/mp4,video/webm,video/quicktime,video/x-matroska" onChange={onSelectVideo} style={{ display: 'none' }} />
1024
1436
  <input id="document-file-input" type="file" accept="application/pdf,.pdf" onChange={onSelectDocument} style={{ display: 'none' }} />
1025
1437
  <button id="screen-capture-btn" type="button" onClick={onCaptureScreen} aria-label="Capture screen">
1026
1438
  <span className="screen-capture-eye" aria-hidden="true" />
@@ -1055,6 +1467,11 @@ export default function ChatPanel({
1055
1467
  else if (provider === 'huggingface') displayName = 'HF'
1056
1468
  else if (provider === 'local_openai') displayName = 'Local'
1057
1469
  else if (provider === 'ollama') displayName = 'Ollama'
1470
+ else if (provider.startsWith('custom:')) {
1471
+ const id = provider.replace(/^custom:/, '')
1472
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
1473
+ displayName = cp?.displayName || id
1474
+ }
1058
1475
  return <option key={provider} value={provider}>{displayName}</option>
1059
1476
  })}
1060
1477
  </select>