@pheem49/mint 1.9.0 → 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 (90) hide show
  1. package/Cargo.lock +55 -3
  2. package/Cargo.toml +2 -1
  3. package/Release_Note.md +285 -140
  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 +287 -43
  8. package/crates/mint-cli/src/gmail.rs +22 -2
  9. package/crates/mint-cli/src/interactive.rs +2888 -0
  10. package/crates/mint-cli/src/main.rs +336 -2854
  11. package/crates/mint-cli/src/markdown.rs +294 -0
  12. package/crates/mint-cli/src/onboard.rs +44 -1
  13. package/crates/mint-cli/src/updater.rs +48 -6
  14. package/crates/mint-core/Cargo.toml +1 -0
  15. package/crates/mint-core/src/agent_loop.rs +1 -0
  16. package/crates/mint-core/src/api_server.rs +376 -13
  17. package/crates/mint-core/src/browser.rs +629 -101
  18. package/crates/mint-core/src/channels.rs +105 -6
  19. package/crates/mint-core/src/chat.rs +52 -4
  20. package/crates/mint-core/src/code_tools.rs +29 -11
  21. package/crates/mint-core/src/config.rs +118 -0
  22. package/crates/mint-core/src/image_gen.rs +294 -33
  23. package/crates/mint-core/src/lib.rs +5 -2
  24. package/crates/mint-core/src/mcp.rs +119 -6
  25. package/crates/mint-core/src/orchestration.rs +133 -11
  26. package/crates/mint-core/src/pictures.rs +64 -1
  27. package/crates/mint-core/src/skills.rs +82 -6
  28. package/crates/mint-core/src/video_gen.rs +235 -0
  29. package/package.json +8 -2
  30. package/shared-modules/luajit/luajit.json +2 -2
  31. package/skills-lock.json +41 -0
  32. package/src/renderer/index-web.html +1 -1
  33. package/src/renderer/index.html +1 -1
  34. package/src/renderer/shared/constants/slashCommands.ts +27 -0
  35. package/src/renderer/shared/platform.ts +3 -0
  36. package/src/renderer/shared/types.ts +4 -0
  37. package/src/renderer/shared/utils/approval.tsx +86 -7
  38. package/src/renderer/shared/utils/markdown.tsx +217 -88
  39. package/src/renderer/shared/utils/slashCommandProcessor.ts +239 -0
  40. package/src/renderer/src/components/ChatPanel.tsx +461 -64
  41. package/src/renderer/src/components/DashboardSidebar.tsx +11 -2
  42. package/src/renderer/src/components/ImageStudioPanel.tsx +87 -4
  43. package/src/renderer/src/components/MintDashboard.tsx +150 -5
  44. package/src/renderer/src/components/PicturesLibrary.tsx +156 -15
  45. package/src/renderer/src/components/Settings/AutomationTab.tsx +316 -18
  46. package/src/renderer/src/components/Settings/GeneralTab.tsx +762 -634
  47. package/src/renderer/src/components/Settings/PluginsTab.tsx +304 -70
  48. package/src/renderer/src/components/Settings/ThemeTab.tsx +6 -1
  49. package/src/renderer/src/components/SettingsWindow.tsx +38 -2
  50. package/src/renderer/src/components/VeoStudioPanel.tsx +549 -0
  51. package/src/renderer/src/css/image-studio.css +106 -0
  52. package/src/renderer/src/css/settings.css +158 -0
  53. package/src/renderer/src/css/styles.css +352 -1
  54. package/src/renderer/src/css/veo-studio.css +626 -0
  55. package/src/renderer/src/index.css +1 -0
  56. package/src/renderer/src/tauri.ts +144 -17
  57. package/src/renderer/src-web/components/ChatPanel.tsx +360 -88
  58. package/src/renderer/src-web/components/DashboardSidebar.tsx +16 -1
  59. package/src/renderer/src-web/components/ImageStudioPanel.tsx +87 -4
  60. package/src/renderer/src-web/components/MintDashboard.tsx +137 -4
  61. package/src/renderer/src-web/components/PicturesLibrary.tsx +155 -15
  62. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +316 -18
  63. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +762 -629
  64. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +304 -70
  65. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +6 -1
  66. package/src/renderer/src-web/components/SettingsWindow.tsx +35 -2
  67. package/src/renderer/src-web/components/VeoStudioPanel.tsx +549 -0
  68. package/src/renderer/src-web/css/image-studio.css +106 -0
  69. package/src/renderer/src-web/css/settings.css +158 -0
  70. package/src/renderer/src-web/css/styles.css +353 -1
  71. package/src/renderer/src-web/css/veo-studio.css +650 -0
  72. package/src/renderer/src-web/index.css +1 -0
  73. package/src/renderer/src-web/tauri.ts +135 -16
  74. package/src-tauri/src/lib.rs +114 -8
  75. package/src-tauri/src/webhooks.rs +45 -31
  76. package/src-tauri/src/workflows.rs +1 -0
  77. package/src-tauri/tauri.conf.json +1 -1
  78. package/temp_gridgeist/AGENTS.md +8 -0
  79. package/temp_gridgeist/CHANGELOG.md +130 -0
  80. package/temp_gridgeist/LICENSE +21 -0
  81. package/temp_gridgeist/README.md +323 -0
  82. package/temp_gridgeist/README.th.md +325 -0
  83. package/temp_gridgeist/skills/gridgeist/SKILL.md +88 -0
  84. package/temp_gridgeist/skills/gridgeist/agents/openai.yaml +9 -0
  85. package/temp_gridgeist/skills/gridgeist/assets/gridgeist-small.svg +10 -0
  86. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.png +0 -0
  87. package/temp_gridgeist/skills/gridgeist/assets/gridgeist.svg +18 -0
  88. package/temp_gridgeist/skills/gridgeist/references/design-language.md +88 -0
  89. package/temp_gridgeist/skills/gridgeist/references/review-checklist.md +128 -0
  90. 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
 
@@ -268,12 +362,154 @@ export default function ChatPanel({
268
362
  const workspaceName = workspacePath
269
363
  ? workspacePath.split(/[\\/]/).filter(Boolean).pop() || workspacePath
270
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
+
271
474
  const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
272
475
  if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
273
476
  event.preventDefault()
274
477
  event.currentTarget.form?.requestSubmit()
275
478
  }
276
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
+ }
277
513
  submitOnEnter(event)
278
514
  }
279
515
 
@@ -444,6 +680,10 @@ export default function ChatPanel({
444
680
  setToolMenuOpen(false)
445
681
  document.getElementById('vision-file-input')?.click()
446
682
  }
683
+ const openVideoPicker = () => {
684
+ setToolMenuOpen(false)
685
+ document.getElementById('video-file-input')?.click()
686
+ }
447
687
  const openDocumentPicker = () => {
448
688
  setToolMenuOpen(false)
449
689
  document.getElementById('document-file-input')?.click()
@@ -730,7 +970,46 @@ export default function ChatPanel({
730
970
  }
731
971
 
732
972
  return (
733
- <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
+ )}
734
1013
  <div className="chat-header">
735
1014
  <div className="chat-header-title">
736
1015
  <img src="./assets/icon.png" alt="Logo" className="chat-header-logo" />
@@ -741,6 +1020,8 @@ export default function ChatPanel({
741
1020
  {interactions.map((interaction) => {
742
1021
  const isSystemEvent = interaction.provider === 'system' && interaction.model === 'provider_change';
743
1022
  if (isSystemEvent) {
1023
+ const rawText = interaction.userText || ''
1024
+ const cleanText = rawText.replace(/^Changed model to\s*/i, '').trim()
744
1025
  return (
745
1026
  <div key={interaction.id} className="system-event-divider">
746
1027
  <div className="system-event-line" />
@@ -748,7 +1029,7 @@ export default function ChatPanel({
748
1029
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
749
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" />
750
1031
  </svg>
751
- <span>{interaction.userText}</span>
1032
+ <span>{cleanText}</span>
752
1033
  </div>
753
1034
  <div className="system-event-line" />
754
1035
  </div>
@@ -760,7 +1041,17 @@ export default function ChatPanel({
760
1041
  <div className="message user-message">
761
1042
  <div className="bubble-wrapper">
762
1043
  <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
763
- <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>
764
1055
  </div>
765
1056
  </div>
766
1057
  )}
@@ -781,20 +1072,30 @@ export default function ChatPanel({
781
1072
  />
782
1073
  )
783
1074
  })()}
784
- <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1075
+ <div className="message-bubble">{renderFormattedMessage(interaction.aiText)}</div>
785
1076
  {renderWebSearchSources(interaction)}
786
1077
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
787
1078
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
788
1079
  {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
789
1080
  <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
790
- <button
791
- type="button"
792
- className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
793
- onClick={() => speak(interaction.aiText)}
794
- title={speakingText === interaction.aiText ? 'Stop reading' : 'Read aloud'}
795
- >
796
- {renderSpeakerIcon(speakingText === interaction.aiText)}
797
- </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>
798
1099
  </div>
799
1100
  </div>
800
1101
  </div>
@@ -804,7 +1105,7 @@ export default function ChatPanel({
804
1105
 
805
1106
  {sending && (
806
1107
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
807
- <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>
808
1109
  {agentMode && (
809
1110
  <AgentActivityDrawer
810
1111
  activityView={agentActivities}
@@ -863,14 +1164,24 @@ export default function ChatPanel({
863
1164
  <button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
864
1165
  {activeFallbackNotice && <span className="provider-fallback-notice">{activeFallbackNotice}</span>}
865
1166
  {streamedReply && (
866
- <button
867
- type="button"
868
- className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
869
- onClick={() => speak(streamedReply)}
870
- title={speakingText === streamedReply ? 'Stop reading' : 'Read aloud'}
871
- >
872
- {renderSpeakerIcon(speakingText === streamedReply)}
873
- </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>
874
1185
  )}
875
1186
  </div>
876
1187
  )}
@@ -937,6 +1248,65 @@ export default function ChatPanel({
937
1248
  </div>
938
1249
  )}
939
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
+
940
1310
  <form
941
1311
  id="chat-form"
942
1312
  onSubmit={onSubmit}
@@ -951,7 +1321,7 @@ export default function ChatPanel({
951
1321
  if (onPasteImage(event.clipboardData)) event.preventDefault()
952
1322
  }}
953
1323
  >
954
- {(imageAttachments.length > 0 || documentName) && (
1324
+ {(imageAttachments.length > 0 || videoAttachments.length > 0 || documentName) && (
955
1325
  <div className="mint-attachment">
956
1326
  {imageAttachments.map((attachment, idx) => (
957
1327
  <div className="mint-image-attachment" key={idx}>
@@ -964,6 +1334,22 @@ export default function ChatPanel({
964
1334
  </button>
965
1335
  </div>
966
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
+ ))}
967
1353
  {documentName && (
968
1354
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
969
1355
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--text-soft)' }}>
@@ -974,7 +1360,7 @@ export default function ChatPanel({
974
1360
  </span>
975
1361
  <span style={{ fontSize: '0.76rem', color: 'var(--text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '220px' }}>{documentName}</span>
976
1362
  <button type="button" onClick={onRemoveDocument} style={{ background: 'transparent', border: 0, color: '#ef4444', cursor: 'pointer', display: 'inline-flex', alignItems: 'center' }}>
977
- <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">
978
1364
  <line x1="18" y1="6" x2="6" y2="18"></line>
979
1365
  <line x1="6" y1="6" x2="18" y2="18"></line>
980
1366
  </svg>
@@ -985,6 +1371,7 @@ export default function ChatPanel({
985
1371
  )}
986
1372
  <textarea
987
1373
  id="chat-input"
1374
+ ref={textareaRef}
988
1375
  value={message}
989
1376
  onChange={(event) => {
990
1377
  resizeInput(event.currentTarget)
@@ -996,46 +1383,56 @@ export default function ChatPanel({
996
1383
  rows={1}
997
1384
  />
998
1385
  <div className="chat-tool-menu-wrap" ref={toolMenuRef}>
999
- <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' }}>
1000
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1001
- <line x1="12" y1="5" x2="12" y2="19"></line>
1002
- <line x1="5" y1="12" x2="19" y2="12"></line>
1003
- </svg>
1004
- </button>
1005
- {toolMenuOpen && (
1006
- <div className="chat-tool-menu" role="menu">
1007
- <button type="button" role="menuitem" onClick={openImagePicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1008
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1009
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1010
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1011
- <circle cx="8.5" cy="8.5" r="1.5"></circle>
1012
- <polyline points="21 15 16 10 5 21"></polyline>
1013
- </svg>
1014
- </span>
1015
- <span>Add image</span>
1016
- </button>
1017
- <button type="button" role="menuitem" onClick={openDocumentPicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1018
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1019
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1020
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1021
- <polyline points="14 2 14 8 20 8"></polyline>
1022
- </svg>
1023
- </span>
1024
- <span>Add file</span>
1025
- </button>
1026
- <button type="button" role="menuitem" onClick={startWebSearch} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1027
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1028
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1029
- <circle cx="11" cy="11" r="8"></circle>
1030
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
1031
- </svg>
1032
- </span>
1033
- <span>Search web</span>
1034
- </button>
1035
- </div>
1036
- )}
1037
- </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>
1038
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' }} />
1039
1436
  <input id="document-file-input" type="file" accept="application/pdf,.pdf" onChange={onSelectDocument} style={{ display: 'none' }} />
1040
1437
  <button id="screen-capture-btn" type="button" onClick={onCaptureScreen} aria-label="Capture screen">
1041
1438
  <span className="screen-capture-eye" aria-hidden="true" />