@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
@@ -55,11 +60,13 @@ interface ChatPanelProps {
55
60
  welcomeInteraction: any
56
61
  onSubmit: (event: FormEvent<HTMLFormElement>) => void
57
62
  onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
63
+ onSelectVideo: (event: ChangeEvent<HTMLInputElement>) => void
58
64
  onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
59
65
  onPasteImage: (clipboardData: DataTransfer) => boolean
60
66
  onSetMessage: (message: string) => void
61
67
  onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
62
68
  onRemoveImage: (idx: number) => void
69
+ onRemoveVideo: (idx: number) => void
63
70
  onRemoveDocument: () => void
64
71
  onStartWebSearch: () => void
65
72
  onCaptureScreen: () => void
@@ -79,6 +86,7 @@ export default function ChatPanel({
79
86
  sending,
80
87
  sendingMessage,
81
88
  sendingImageCount,
89
+ sendingVideoCount,
82
90
  streamedReply,
83
91
  streamedResponse,
84
92
  agentProgress,
@@ -87,6 +95,7 @@ export default function ChatPanel({
87
95
  onThinkingExpandedChange,
88
96
  message,
89
97
  imageAttachments,
98
+ videoAttachments,
90
99
  documentName,
91
100
  pendingApproval,
92
101
  smartContext,
@@ -96,11 +105,13 @@ export default function ChatPanel({
96
105
  welcomeInteraction,
97
106
  onSubmit,
98
107
  onSelectImage,
108
+ onSelectVideo,
99
109
  onSelectDocument,
100
110
  onPasteImage,
101
111
  onSetMessage,
102
112
  onSendVoiceMessage,
103
113
  onRemoveImage,
114
+ onRemoveVideo,
104
115
  onRemoveDocument,
105
116
  onStartWebSearch,
106
117
  onCaptureScreen,
@@ -150,8 +161,9 @@ export default function ChatPanel({
150
161
  }
151
162
  fetchOllamaModels();
152
163
  }, [status?.activeProvider, settingsConfig?.ollamaHost])
153
- const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
164
+ const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || videoAttachments.length > 0 || documentName)
154
165
  const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
166
+ const sendingVideoMarkers = Array.from({ length: sendingVideoCount || 0 }, (_, index) => `[Video #${index + 1}]`).join(' ')
155
167
 
156
168
  const getAvailableModels = (provider: string) => {
157
169
  switch (provider) {
@@ -234,6 +246,22 @@ export default function ChatPanel({
234
246
  }, [sending])
235
247
 
236
248
  const [speakingText, setSpeakingText] = useState<string | null>(null)
249
+ const [copiedId, setCopiedId] = useState<string | number | null>(null)
250
+
251
+ const handleCopyMessage = async (id: string | number, text: string) => {
252
+ try {
253
+ const cleanText = readableAssistantText(text) || text
254
+ await navigator.clipboard.writeText(cleanText)
255
+ setCopiedId(id)
256
+ setTimeout(() => {
257
+ setCopiedId((current) => (current === id ? null : current))
258
+ }, 2000)
259
+ } catch (err) {
260
+ console.error('Failed to copy message:', err)
261
+ }
262
+ }
263
+
264
+
237
265
  const audioRef = useRef<HTMLAudioElement | null>(null)
238
266
  const speechRunRef = useRef(0)
239
267
  const historyReadyRef = useRef(false)
@@ -437,6 +465,15 @@ export default function ChatPanel({
437
465
  const event = { target: input } as ChangeEvent<HTMLInputElement>
438
466
  onSelectImage(event)
439
467
  }
468
+ } else if (file.type.startsWith('video/')) {
469
+ const input = document.getElementById('video-file-input') as HTMLInputElement | null
470
+ if (input) {
471
+ const dt = new DataTransfer()
472
+ dt.items.add(file)
473
+ input.files = dt.files
474
+ const event = { target: input } as ChangeEvent<HTMLInputElement>
475
+ onSelectVideo(event)
476
+ }
440
477
  } else if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
441
478
  const input = document.getElementById('document-file-input') as HTMLInputElement | null
442
479
  if (input) {
@@ -449,12 +486,154 @@ export default function ChatPanel({
449
486
  }
450
487
  }
451
488
  }
489
+ const [skillsList, setSkillsList] = useState<LearnedSkill[]>([])
490
+ const [slashMenuOpen, setSlashMenuOpen] = useState(true)
491
+ const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
492
+ const slashMenuRef = useRef<HTMLDivElement>(null)
493
+ const slashListRef = useRef<HTMLDivElement>(null)
494
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
495
+
496
+ const isSlashInput = message.trimStart().startsWith('/')
497
+ const isSkillInput = message.trimStart().startsWith('$')
498
+ const atMatch = message.match(/@([\w\-\.\/]*)$/)
499
+ const isAtInput = Boolean(atMatch)
500
+ const atQuery = atMatch ? atMatch[1].toLowerCase() : ''
501
+
502
+ const CONTEXT_SUGGESTIONS = [
503
+ { label: '@workspace', desc: 'Include workspace path & context' },
504
+ { label: '@file', desc: 'Reference workspace file' },
505
+ { label: '@docs', desc: 'Include documentation context' },
506
+ { label: '@memory', desc: 'Include long-term memory store' },
507
+ ]
508
+
509
+ const filteredContexts = isAtInput
510
+ ? CONTEXT_SUGGESTIONS.filter((item) => item.label.toLowerCase().includes(atQuery))
511
+ : []
512
+
513
+ useEffect(() => {
514
+ let isMounted = true
515
+ listLearnedSkills()
516
+ .then((res) => {
517
+ if (isMounted && Array.isArray(res)) setSkillsList(res)
518
+ })
519
+ .catch(() => {})
520
+ return () => { isMounted = false }
521
+ }, [isSkillInput])
522
+
523
+ const slashQuery = isSlashInput ? message.trimStart().toLowerCase() : ''
524
+ const filteredSlashCommands = isSlashInput
525
+ ? SLASH_COMMANDS.filter((c) => c.command.toLowerCase().startsWith(slashQuery))
526
+ : []
527
+
528
+ const skillQuery = isSkillInput ? message.trimStart().slice(1).toLowerCase() : ''
529
+ const filteredSkills = isSkillInput
530
+ ? skillsList.filter((s) => s.name.toLowerCase().startsWith(skillQuery))
531
+ : []
532
+
533
+ const showSuggestionMenu = slashMenuOpen && (
534
+ (isSlashInput && filteredSlashCommands.length > 0) ||
535
+ (isSkillInput && filteredSkills.length > 0) ||
536
+ (isAtInput && filteredContexts.length > 0)
537
+ )
538
+
539
+ const suggestionCount = isSlashInput
540
+ ? filteredSlashCommands.length
541
+ : isSkillInput
542
+ ? filteredSkills.length
543
+ : filteredContexts.length
544
+
545
+ useEffect(() => {
546
+ if (isSlashInput || isSkillInput || isAtInput) {
547
+ setSlashMenuOpen(true)
548
+ setSlashSelectedIndex(0)
549
+ }
550
+ }, [message])
551
+
552
+ useEffect(() => {
553
+ if (showSuggestionMenu && slashListRef.current) {
554
+ const activeItem = slashListRef.current.children[slashSelectedIndex] as HTMLElement
555
+ if (activeItem) {
556
+ activeItem.scrollIntoView({ block: 'nearest' })
557
+ }
558
+ }
559
+ }, [slashSelectedIndex, showSuggestionMenu])
560
+
561
+ useEffect(() => {
562
+ const handleClickOutside = (event: MouseEvent) => {
563
+ if (
564
+ slashMenuRef.current &&
565
+ !slashMenuRef.current.contains(event.target as Node) &&
566
+ textareaRef.current &&
567
+ !textareaRef.current.contains(event.target as Node)
568
+ ) {
569
+ setSlashMenuOpen(false)
570
+ }
571
+ }
572
+ document.addEventListener('mousedown', handleClickOutside)
573
+ return () => document.removeEventListener('mousedown', handleClickOutside)
574
+ }, [])
575
+
576
+ const selectSlashCommand = (cmd: string) => {
577
+ onSetMessage(cmd + ' ')
578
+ setSlashMenuOpen(false)
579
+ setSlashSelectedIndex(0)
580
+ setTimeout(() => textareaRef.current?.focus(), 0)
581
+ }
582
+
583
+ const selectSkillCommand = (name: string) => {
584
+ onSetMessage('$' + name + ' ')
585
+ setSlashMenuOpen(false)
586
+ setSlashSelectedIndex(0)
587
+ setTimeout(() => textareaRef.current?.focus(), 0)
588
+ }
589
+
590
+ const selectAtCommand = (label: string) => {
591
+ const nextMsg = message.replace(/@[\w\-\.\/]*$/, label + ' ')
592
+ onSetMessage(nextMsg)
593
+ setSlashMenuOpen(false)
594
+ setSlashSelectedIndex(0)
595
+ setTimeout(() => textareaRef.current?.focus(), 0)
596
+ }
597
+
452
598
  const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
453
599
  if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
454
600
  event.preventDefault()
455
601
  event.currentTarget.form?.requestSubmit()
456
602
  }
457
603
  const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
604
+ if (showSuggestionMenu) {
605
+ if (event.key === 'ArrowDown') {
606
+ event.preventDefault()
607
+ setSlashSelectedIndex((prev) => (prev + 1) % suggestionCount)
608
+ return
609
+ }
610
+ if (event.key === 'ArrowUp') {
611
+ event.preventDefault()
612
+ setSlashSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount)
613
+ return
614
+ }
615
+ if (event.key === 'Enter' || event.key === 'Tab') {
616
+ if (!event.shiftKey && !event.nativeEvent.isComposing) {
617
+ event.preventDefault()
618
+ if (isSlashInput) {
619
+ const selected = filteredSlashCommands[slashSelectedIndex] || filteredSlashCommands[0]
620
+ if (selected) selectSlashCommand(selected.command)
621
+ } else if (isSkillInput) {
622
+ const selected = filteredSkills[slashSelectedIndex] || filteredSkills[0]
623
+ if (selected) selectSkillCommand(selected.name)
624
+ } else if (isAtInput) {
625
+ const selected = filteredContexts[slashSelectedIndex] || filteredContexts[0]
626
+ if (selected) selectAtCommand(selected.label)
627
+ }
628
+ return
629
+ }
630
+ }
631
+ if (event.key === 'Escape') {
632
+ event.preventDefault()
633
+ setSlashMenuOpen(false)
634
+ return
635
+ }
636
+ }
458
637
  submitOnEnter(event)
459
638
  }
460
639
  const resizeInput = (element: HTMLTextAreaElement) => {
@@ -500,6 +679,10 @@ export default function ChatPanel({
500
679
  setToolMenuOpen(false)
501
680
  document.getElementById('vision-file-input')?.click()
502
681
  }
682
+ const openVideoPicker = () => {
683
+ setToolMenuOpen(false)
684
+ document.getElementById('video-file-input')?.click()
685
+ }
503
686
  const openDocumentPicker = () => {
504
687
  setToolMenuOpen(false)
505
688
  document.getElementById('document-file-input')?.click()
@@ -825,7 +1008,7 @@ export default function ChatPanel({
825
1008
  >
826
1009
  <div style={{ fontSize: '3.5rem', marginBottom: '16px' }}>🖼️</div>
827
1010
  <div style={{ fontSize: '1.25rem', fontWeight: 'bold', letterSpacing: '0.5px' }}>Drag files to attach data</div>
828
- <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>Supports images (PNG, JPEG, WebP, GIF) and PDF files</div>
1011
+ <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>Supports images (PNG, JPEG, WebP, GIF), videos (MP4, WebM, MOV, MKV), and PDF files</div>
829
1012
  </div>
830
1013
  )}
831
1014
 
@@ -833,6 +1016,8 @@ export default function ChatPanel({
833
1016
  {interactions.map((interaction) => {
834
1017
  const isSystemEvent = interaction.provider === 'system' && interaction.model === 'provider_change';
835
1018
  if (isSystemEvent) {
1019
+ const rawText = interaction.userText || ''
1020
+ const cleanText = rawText.replace(/^Changed model to\s*/i, '').trim()
836
1021
  return (
837
1022
  <div key={interaction.id} className="system-event-divider">
838
1023
  <div className="system-event-line" />
@@ -840,7 +1025,7 @@ export default function ChatPanel({
840
1025
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
841
1026
  <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" />
842
1027
  </svg>
843
- <span>{interaction.userText}</span>
1028
+ <span>{cleanText}</span>
844
1029
  </div>
845
1030
  <div className="system-event-line" />
846
1031
  </div>
@@ -852,7 +1037,17 @@ export default function ChatPanel({
852
1037
  <div className="message user-message">
853
1038
  <div className="bubble-wrapper">
854
1039
  <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
855
- <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1040
+ <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1041
+ <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
1042
+ <button
1043
+ type="button"
1044
+ className={`msg-action-btn copy-btn ${copiedId === `user-${interaction.id}` ? 'is-copied' : ''}`}
1045
+ onClick={() => handleCopyMessage(`user-${interaction.id}`, interaction.userText)}
1046
+ title={copiedId === `user-${interaction.id}` ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy)'}
1047
+ >
1048
+ {renderCopyIcon(copiedId === `user-${interaction.id}`)}
1049
+ </button>
1050
+ </div>
856
1051
  </div>
857
1052
  </div>
858
1053
  )}
@@ -873,32 +1068,30 @@ export default function ChatPanel({
873
1068
  />
874
1069
  )
875
1070
  })()}
876
- <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1071
+ <div className="message-bubble">{renderFormattedMessage(interaction.aiText)}</div>
877
1072
  {renderWebSearchSources(interaction)}
878
1073
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
879
1074
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
880
1075
  {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
881
1076
  <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
882
- <button
883
- type="button"
884
- className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
885
- onClick={() => speak(interaction.aiText)}
886
- style={{
887
- background: 'transparent',
888
- border: 'none',
889
- color: speakingText === interaction.aiText ? 'var(--accent)' : 'var(--text-soft)',
890
- cursor: 'pointer',
891
- fontSize: '0.85rem',
892
- padding: '2px',
893
- display: 'inline-flex',
894
- alignItems: 'center',
895
- opacity: 0.7,
896
- transition: 'all 0.2s',
897
- }}
898
- title={speakingText === interaction.aiText ? "Stop reading" : "Read aloud"}
899
- >
900
- {renderSpeakerIcon(speakingText === interaction.aiText)}
901
- </button>
1077
+ <div className="message-action-buttons" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto' }}>
1078
+ <button
1079
+ type="button"
1080
+ className={`msg-action-btn copy-btn ${copiedId === interaction.id ? 'is-copied' : ''}`}
1081
+ onClick={() => handleCopyMessage(interaction.id, interaction.aiText)}
1082
+ title={copiedId === interaction.id ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy message)'}
1083
+ >
1084
+ {renderCopyIcon(copiedId === interaction.id)}
1085
+ </button>
1086
+ <button
1087
+ type="button"
1088
+ className={`msg-action-btn tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
1089
+ onClick={() => speak(interaction.aiText)}
1090
+ title={speakingText === interaction.aiText ? 'Stop reading' : 'Read aloud'}
1091
+ >
1092
+ {renderSpeakerIcon(speakingText === interaction.aiText)}
1093
+ </button>
1094
+ </div>
902
1095
  </div>
903
1096
  </div>
904
1097
  </div>
@@ -908,7 +1101,7 @@ export default function ChatPanel({
908
1101
 
909
1102
  {sending && (
910
1103
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
911
- <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
1104
+ <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{renderFormattedMessage([sendingMessage, sendingImageMarkers, sendingVideoMarkers].filter(Boolean).join(' '))}</div></div></div>
912
1105
  {agentMode && (
913
1106
  <AgentActivityDrawer
914
1107
  activityView={agentActivities}
@@ -967,26 +1160,24 @@ export default function ChatPanel({
967
1160
  <button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
968
1161
  {activeFallbackNotice && <span className="provider-fallback-notice">{activeFallbackNotice}</span>}
969
1162
  {streamedReply && (
970
- <button
971
- type="button"
972
- className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
973
- onClick={() => speak(streamedReply)}
974
- style={{
975
- background: 'transparent',
976
- border: 'none',
977
- color: speakingText === streamedReply ? 'var(--accent)' : 'var(--text-soft)',
978
- cursor: 'pointer',
979
- fontSize: '0.85rem',
980
- padding: '2px',
981
- display: 'inline-flex',
982
- alignItems: 'center',
983
- opacity: 0.7,
984
- transition: 'all 0.2s',
985
- }}
986
- title={speakingText === streamedReply ? "Stop reading" : "Read aloud"}
987
- >
988
- {renderSpeakerIcon(speakingText === streamedReply)}
989
- </button>
1163
+ <div className="message-action-buttons" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto' }}>
1164
+ <button
1165
+ type="button"
1166
+ className={`msg-action-btn copy-btn ${copiedId === 'live' ? 'is-copied' : ''}`}
1167
+ onClick={() => handleCopyMessage('live', streamedReply)}
1168
+ title={copiedId === 'live' ? 'คัดลอกแล้ว (Copied!)' : 'คัดลอกข้อความ (Copy message)'}
1169
+ >
1170
+ {renderCopyIcon(copiedId === 'live')}
1171
+ </button>
1172
+ <button
1173
+ type="button"
1174
+ className={`msg-action-btn tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
1175
+ onClick={() => speak(streamedReply)}
1176
+ title={speakingText === streamedReply ? 'Stop reading' : 'Read aloud'}
1177
+ >
1178
+ {renderSpeakerIcon(speakingText === streamedReply)}
1179
+ </button>
1180
+ </div>
990
1181
  )}
991
1182
  </div>
992
1183
  )}
@@ -1037,6 +1228,65 @@ export default function ChatPanel({
1037
1228
  </div>
1038
1229
  )}
1039
1230
 
1231
+ {showSuggestionMenu && (
1232
+ <div className="slash-suggestions-popup" ref={slashMenuRef}>
1233
+ <div className="slash-suggestions-header">
1234
+ {isSlashInput ? 'Slash Commands' : isSkillInput ? 'Learned Skills' : 'Context Mentions'} ({slashSelectedIndex + 1}/{suggestionCount})
1235
+ </div>
1236
+ <div className="slash-suggestions-list" ref={slashListRef}>
1237
+ {isSlashInput
1238
+ ? filteredSlashCommands.map((item, idx) => (
1239
+ <button
1240
+ key={item.command}
1241
+ type="button"
1242
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1243
+ onMouseDown={(e) => {
1244
+ e.preventDefault()
1245
+ selectSlashCommand(item.command)
1246
+ }}
1247
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1248
+ >
1249
+ <span className="slash-cmd-name">{item.command}</span>
1250
+ <span className="slash-cmd-desc">{item.description}</span>
1251
+ </button>
1252
+ ))
1253
+ : isSkillInput
1254
+ ? filteredSkills.map((item, idx) => (
1255
+ <button
1256
+ key={item.id || item.name}
1257
+ type="button"
1258
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1259
+ onMouseDown={(e) => {
1260
+ e.preventDefault()
1261
+ selectSkillCommand(item.name)
1262
+ }}
1263
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1264
+ >
1265
+ <span className="slash-cmd-name">${item.name}</span>
1266
+ <span className="skill-badge">[Skill]</span>
1267
+ <span className="slash-cmd-desc">{item.description || item.content?.slice(0, 60)}</span>
1268
+ </button>
1269
+ ))
1270
+ : filteredContexts.map((item, idx) => (
1271
+ <button
1272
+ key={item.label}
1273
+ type="button"
1274
+ className={`slash-suggestion-item ${idx === slashSelectedIndex ? 'active' : ''}`}
1275
+ onMouseDown={(e) => {
1276
+ e.preventDefault()
1277
+ selectAtCommand(item.label)
1278
+ }}
1279
+ onMouseEnter={() => setSlashSelectedIndex(idx)}
1280
+ >
1281
+ <span className="slash-cmd-name">{item.label}</span>
1282
+ <span className="skill-badge">[Context]</span>
1283
+ <span className="slash-cmd-desc">{item.desc}</span>
1284
+ </button>
1285
+ ))}
1286
+ </div>
1287
+ </div>
1288
+ )}
1289
+
1040
1290
  <form
1041
1291
  id="chat-form"
1042
1292
  onSubmit={onSubmit}
@@ -1044,7 +1294,7 @@ export default function ChatPanel({
1044
1294
  if (onPasteImage(event.clipboardData)) event.preventDefault()
1045
1295
  }}
1046
1296
  >
1047
- {(imageAttachments.length > 0 || documentName) && (
1297
+ {(imageAttachments.length > 0 || videoAttachments.length > 0 || documentName) && (
1048
1298
  <div className="mint-attachment">
1049
1299
  {imageAttachments.map((attachment, idx) => (
1050
1300
  <div className="mint-image-attachment" key={idx}>
@@ -1052,6 +1302,17 @@ export default function ChatPanel({
1052
1302
  <button className="mint-attachment-remove" type="button" onClick={() => onRemoveImage(idx)} aria-label="Remove image">×</button>
1053
1303
  </div>
1054
1304
  ))}
1305
+ {videoAttachments.map((attachment, idx) => (
1306
+ <div className="mint-image-attachment" key={idx}>
1307
+ <video className="mint-image-preview" src={attachment.dataUri} muted playsInline preload="metadata" />
1308
+ <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' }}>
1309
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="white" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1310
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
1311
+ </svg>
1312
+ </div>
1313
+ <button className="mint-attachment-remove" type="button" onClick={() => onRemoveVideo(idx)} aria-label="Remove video">×</button>
1314
+ </div>
1315
+ ))}
1055
1316
  {documentName && (
1056
1317
  <div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
1057
1318
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--accent)' }}>
@@ -1070,6 +1331,7 @@ export default function ChatPanel({
1070
1331
  )}
1071
1332
  <textarea
1072
1333
  id="chat-input"
1334
+ ref={textareaRef}
1073
1335
  value={message}
1074
1336
  onChange={(event) => {
1075
1337
  resizeInput(event.currentTarget)
@@ -1080,47 +1342,57 @@ export default function ChatPanel({
1080
1342
  rows={1}
1081
1343
  />
1082
1344
  <div className="chat-tool-menu-wrap" ref={toolMenuRef}>
1083
- <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' }}>
1084
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
1085
- <line x1="12" y1="5" x2="12" y2="19"></line>
1086
- <line x1="5" y1="12" x2="19" y2="12"></line>
1087
- </svg>
1088
- </button>
1089
- {toolMenuOpen && (
1090
- <div className="chat-tool-menu" role="menu">
1091
- <button type="button" role="menuitem" onClick={openImagePicker}>
1092
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1093
- <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1094
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1095
- <circle cx="8.5" cy="8.5" r="1.5"></circle>
1096
- <polyline points="21 15 16 10 5 21"></polyline>
1097
- </svg>
1098
- </span>
1099
- <span>Add image</span>
1100
- </button>
1101
- <button type="button" role="menuitem" onClick={openDocumentPicker}>
1102
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1103
- <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1104
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1105
- <polyline points="14 2 14 8 20 8"></polyline>
1106
- </svg>
1107
- </span>
1108
- <span>Add file</span>
1109
- </button>
1110
- <button type="button" role="menuitem" onClick={startWebSearch}>
1111
- <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1112
- <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1113
- <circle cx="12" cy="12" r="10"></circle>
1114
- <line x1="2" y1="12" x2="22" y2="12"></line>
1115
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
1116
- </svg>
1117
- </span>
1118
- <span>Search web</span>
1119
- </button>
1120
- </div>
1121
- )}
1122
- </div>
1345
+ <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' }}>
1346
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
1347
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1348
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1349
+ </svg>
1350
+ </button>
1351
+ {toolMenuOpen && (
1352
+ <div className="chat-tool-menu" role="menu">
1353
+ <button type="button" role="menuitem" onClick={openImagePicker}>
1354
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1355
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1356
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1357
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
1358
+ <polyline points="21 15 16 10 5 21"></polyline>
1359
+ </svg>
1360
+ </span>
1361
+ <span>Add image</span>
1362
+ </button>
1363
+ <button type="button" role="menuitem" onClick={openVideoPicker}>
1364
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1365
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1366
+ <polygon points="23 7 16 12 23 17 23 7"></polygon>
1367
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
1368
+ </svg>
1369
+ </span>
1370
+ <span>Add video</span>
1371
+ </button>
1372
+ <button type="button" role="menuitem" onClick={openDocumentPicker}>
1373
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1374
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1375
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1376
+ <polyline points="14 2 14 8 20 8"></polyline>
1377
+ </svg>
1378
+ </span>
1379
+ <span>Add file</span>
1380
+ </button>
1381
+ <button type="button" role="menuitem" onClick={startWebSearch}>
1382
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1383
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1384
+ <circle cx="12" cy="12" r="10"></circle>
1385
+ <line x1="2" y1="12" x2="22" y2="12"></line>
1386
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
1387
+ </svg>
1388
+ </span>
1389
+ <span>Search web</span>
1390
+ </button>
1391
+ </div>
1392
+ )}
1393
+ </div>
1123
1394
  <input id="vision-file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={onSelectImage} style={{ display: 'none' }} />
1395
+ <input id="video-file-input" type="file" accept="video/mp4,video/webm,video/quicktime,video/x-matroska" onChange={onSelectVideo} style={{ display: 'none' }} />
1124
1396
  <input id="document-file-input" type="file" accept="application/pdf,.pdf" onChange={onSelectDocument} style={{ display: 'none' }} />
1125
1397
  <div className="chat-provider-select" style={{ display: 'flex', gap: '4px', padding: 0, background: 'transparent', border: 0, width: '100%', height: '32px' }}>
1126
1398
  <select