@pheem49/mint 1.5.5 → 1.6.1

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 (222) hide show
  1. package/.codex +0 -0
  2. package/.github/FUNDING.yml +2 -0
  3. package/.github/workflows/ci.yml +45 -0
  4. package/.github/workflows/release.yml +79 -0
  5. package/Cargo.lock +5792 -0
  6. package/Cargo.toml +32 -0
  7. package/README.md +387 -353
  8. package/assets/icon.png +0 -0
  9. package/bin/mint +0 -0
  10. package/crates/mint-cli/Cargo.toml +23 -0
  11. package/crates/mint-cli/src/agent.rs +851 -0
  12. package/crates/mint-cli/src/gmail.rs +216 -0
  13. package/crates/mint-cli/src/image.rs +142 -0
  14. package/crates/mint-cli/src/main.rs +2837 -0
  15. package/crates/mint-cli/src/mcp.rs +63 -0
  16. package/crates/mint-cli/src/onboard.rs +1149 -0
  17. package/crates/mint-cli/src/setup.rs +390 -0
  18. package/crates/mint-cli/src/skills.rs +8 -0
  19. package/crates/mint-cli/src/updater.rs +279 -0
  20. package/crates/mint-core/Cargo.toml +22 -0
  21. package/crates/mint-core/src/agent_loop.rs +94 -0
  22. package/crates/mint-core/src/api_server.rs +991 -0
  23. package/crates/mint-core/src/channels.rs +248 -0
  24. package/crates/mint-core/src/chat.rs +895 -0
  25. package/crates/mint-core/src/code_tools.rs +729 -0
  26. package/crates/mint-core/src/config.rs +368 -0
  27. package/crates/mint-core/src/files.rs +159 -0
  28. package/crates/mint-core/src/knowledge.rs +541 -0
  29. package/crates/mint-core/src/lib.rs +84 -0
  30. package/crates/mint-core/src/mcp.rs +273 -0
  31. package/crates/mint-core/src/memory.rs +673 -0
  32. package/crates/mint-core/src/orchestration.rs +2157 -0
  33. package/crates/mint-core/src/pictures.rs +314 -0
  34. package/crates/mint-core/src/plugins.rs +727 -0
  35. package/crates/mint-core/src/safety.rs +416 -0
  36. package/crates/mint-core/src/semantic.rs +254 -0
  37. package/crates/mint-core/src/shell.rs +317 -0
  38. package/crates/mint-core/src/skills.rs +71 -0
  39. package/crates/mint-core/src/symbols.rs +157 -0
  40. package/crates/mint-core/src/tasks.rs +308 -0
  41. package/crates/mint-core/src/tts.rs +92 -0
  42. package/crates/mint-core/src/weather.rs +93 -0
  43. package/crates/mint-core/src/web_search.rs +200 -0
  44. package/crates/mint-core/src/workflows.rs +81 -0
  45. package/crates/mint-core/tests/mcp_stdio.rs +45 -0
  46. package/crates/mint-core/tests/memory_persistence.rs +172 -0
  47. package/crates/mint-core/tests/pictures_storage.rs +14 -0
  48. package/crates/mint-core/tests/task_lifecycle.rs +87 -0
  49. package/package.json +35 -99
  50. package/src/bin/index.js +16 -0
  51. package/src/renderer/index-web.html +17 -0
  52. package/src/renderer/index.html +17 -0
  53. package/src/renderer/public/Live2DCubismCore.js +9 -0
  54. package/src/renderer/public/assets/icon.png +0 -0
  55. package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
  56. package/src/renderer/src/App.tsx +33 -0
  57. package/src/renderer/src/calculator.ts +47 -0
  58. package/src/renderer/src/components/ChatPanel.tsx +1598 -0
  59. package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
  60. package/src/renderer/src/components/Live2DStage.tsx +374 -0
  61. package/src/renderer/src/components/MintDashboard.tsx +950 -0
  62. package/src/renderer/src/components/ModelPanel.tsx +154 -0
  63. package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
  64. package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
  65. package/src/renderer/src/components/ScreenPicker.tsx +579 -0
  66. package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
  67. package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
  68. package/src/renderer/src/components/WidgetWindow.tsx +36 -0
  69. package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
  70. package/src/{UI → renderer/src/css}/settings.css +69 -16
  71. package/src/renderer/src/css/spotlight.css +113 -0
  72. package/src/renderer/src/css/styles.css +3722 -0
  73. package/src/renderer/src/css/widget.css +185 -0
  74. package/src/renderer/src/env.d.ts +116 -0
  75. package/src/renderer/src/index.css +379 -0
  76. package/src/renderer/src/main.tsx +13 -0
  77. package/src/renderer/src/tauri.ts +996 -0
  78. package/src/renderer/src-web/App.tsx +25 -0
  79. package/src/renderer/src-web/calculator.ts +47 -0
  80. package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
  81. package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
  82. package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
  83. package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
  84. package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
  85. package/src/renderer/src-web/css/settings.css +1100 -0
  86. package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
  87. package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
  88. package/src/{UI → renderer/src-web/css}/widget.css +2 -2
  89. package/src/renderer/src-web/env.d.ts +107 -0
  90. package/src/renderer/src-web/index.css +379 -0
  91. package/src/renderer/src-web/main.tsx +13 -0
  92. package/src/renderer/src-web/tauri.ts +983 -0
  93. package/tsconfig.json +30 -0
  94. package/vite.config.ts +33 -0
  95. package/vite.config.web.ts +51 -0
  96. package/GUIDE_TH.md +0 -125
  97. package/assets/Agent_Mint.png +0 -0
  98. package/assets/CLI_Screen.png +0 -0
  99. package/assets/Settings.png +0 -0
  100. package/benchmark_ai.js +0 -71
  101. package/install.ps1 +0 -64
  102. package/install.sh +0 -54
  103. package/main.js +0 -139
  104. package/mint-cli-logic.js +0 -3
  105. package/mint-cli.js +0 -410
  106. package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
  107. package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
  108. package/preload-picker.js +0 -11
  109. package/preload-settings.js +0 -11
  110. package/preload.js +0 -41
  111. package/scripts/install_linux_desktop_entry.js +0 -48
  112. package/src/AI_Brain/Gemini_API.js +0 -813
  113. package/src/AI_Brain/agent_orchestrator.js +0 -73
  114. package/src/AI_Brain/autonomous_brain.js +0 -179
  115. package/src/AI_Brain/behavior_memory.js +0 -135
  116. package/src/AI_Brain/headless_agent.js +0 -143
  117. package/src/AI_Brain/knowledge_base.js +0 -349
  118. package/src/AI_Brain/memory_store.js +0 -662
  119. package/src/AI_Brain/proactive_engine.js +0 -172
  120. package/src/AI_Brain/provider_adapter.js +0 -365
  121. package/src/Automation_Layer/browser_automation.js +0 -149
  122. package/src/Automation_Layer/file_operations.js +0 -286
  123. package/src/Automation_Layer/open_app.js +0 -85
  124. package/src/Automation_Layer/open_website.js +0 -38
  125. package/src/CLI/approval_handler.js +0 -47
  126. package/src/CLI/chat_router.js +0 -247
  127. package/src/CLI/chat_ui.js +0 -1159
  128. package/src/CLI/cli_colors.js +0 -115
  129. package/src/CLI/cli_formatters.js +0 -94
  130. package/src/CLI/code_agent.js +0 -1667
  131. package/src/CLI/code_session_memory.js +0 -62
  132. package/src/CLI/gmail_auth.js +0 -210
  133. package/src/CLI/image_input.js +0 -90
  134. package/src/CLI/intent_detectors.js +0 -181
  135. package/src/CLI/interactive_chat.js +0 -658
  136. package/src/CLI/list_features.js +0 -64
  137. package/src/CLI/onboarding.js +0 -416
  138. package/src/CLI/repo_summarizer.js +0 -282
  139. package/src/CLI/semantic_code_search.js +0 -312
  140. package/src/CLI/skill_manager.js +0 -41
  141. package/src/CLI/slash_command_handler.js +0 -418
  142. package/src/CLI/symbol_indexer.js +0 -231
  143. package/src/CLI/updater.js +0 -230
  144. package/src/CLI/workspace_manager.js +0 -90
  145. package/src/Channels/brave_search_bridge.js +0 -35
  146. package/src/Channels/discord_bridge.js +0 -66
  147. package/src/Channels/google_search_bridge.js +0 -38
  148. package/src/Channels/line_bridge.js +0 -60
  149. package/src/Channels/slack_bridge.js +0 -48
  150. package/src/Channels/telegram_bridge.js +0 -41
  151. package/src/Channels/whatsapp_bridge.js +0 -57
  152. package/src/Command_Parser/parser.js +0 -45
  153. package/src/Plugins/dev_tools.js +0 -41
  154. package/src/Plugins/discord.js +0 -20
  155. package/src/Plugins/docker.js +0 -47
  156. package/src/Plugins/gmail.js +0 -251
  157. package/src/Plugins/google_calendar.js +0 -252
  158. package/src/Plugins/mcp_manager.js +0 -95
  159. package/src/Plugins/notion.js +0 -256
  160. package/src/Plugins/obsidian.js +0 -54
  161. package/src/Plugins/plugin_manager.js +0 -81
  162. package/src/Plugins/spotify.js +0 -173
  163. package/src/Plugins/system_metrics.js +0 -31
  164. package/src/Plugins/system_monitor.js +0 -72
  165. package/src/System/action_executor.js +0 -178
  166. package/src/System/bridge_manager.js +0 -76
  167. package/src/System/chat_history_manager.js +0 -83
  168. package/src/System/config_manager.js +0 -194
  169. package/src/System/custom_workflows.js +0 -163
  170. package/src/System/daemon_manager.js +0 -67
  171. package/src/System/google_tts_urls.js +0 -51
  172. package/src/System/granular_automation.js +0 -157
  173. package/src/System/ipc_handlers.js +0 -332
  174. package/src/System/notifications.js +0 -23
  175. package/src/System/optional_require.js +0 -23
  176. package/src/System/picture_store.js +0 -109
  177. package/src/System/proactive_loop.js +0 -153
  178. package/src/System/safety_manager.js +0 -273
  179. package/src/System/sandbox_runner.js +0 -182
  180. package/src/System/screen_capture.js +0 -175
  181. package/src/System/smart_context.js +0 -227
  182. package/src/System/system_automation.js +0 -162
  183. package/src/System/system_events.js +0 -79
  184. package/src/System/system_info.js +0 -125
  185. package/src/System/task_manager.js +0 -222
  186. package/src/System/tool_registry.js +0 -293
  187. package/src/System/window_manager.js +0 -220
  188. package/src/UI/floating.css +0 -80
  189. package/src/UI/floating.html +0 -17
  190. package/src/UI/floating.js +0 -67
  191. package/src/UI/live2d_manager.js +0 -600
  192. package/src/UI/preload-floating.js +0 -7
  193. package/src/UI/preload-spotlight.js +0 -11
  194. package/src/UI/preload-widget.js +0 -5
  195. package/src/UI/proactive-glow.html +0 -42
  196. package/src/UI/renderer.js +0 -2127
  197. package/src/UI/screenPicker.html +0 -214
  198. package/src/UI/screenPicker.js +0 -262
  199. package/src/UI/settings.html +0 -577
  200. package/src/UI/settings.js +0 -770
  201. package/src/UI/spotlight.html +0 -23
  202. package/src/UI/spotlight.js +0 -185
  203. package/src/UI/widget.html +0 -29
  204. package/src/UI/widget.js +0 -10
  205. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
  206. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
  207. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
  208. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
  209. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
  210. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
  211. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
  212. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
  213. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
  214. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
  215. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
  216. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
  217. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
  218. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
  219. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
  220. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
  221. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
  222. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
@@ -0,0 +1,1662 @@
1
+ import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type FormEvent, type KeyboardEvent, type RefObject, type DragEvent } from 'react'
2
+ import {
3
+ type AgentProgress,
4
+ type ChatResponse,
5
+ type RuntimeStatus,
6
+ getTtsUrls,
7
+ } from '../tauri'
8
+
9
+ const GEMINI_MODELS = [
10
+ 'gemini-2.5-flash',
11
+ 'gemini-2.5-pro',
12
+ 'gemini-2.0-flash',
13
+ 'gemini-1.5-flash',
14
+ 'gemini-1.5-pro',
15
+ 'gemini-3.1-flash-lite',
16
+ 'gemini-3.1-flash-lite-preview'
17
+ ]
18
+
19
+ const OPENAI_MODELS = [
20
+ 'gpt-4o',
21
+ 'gpt-4o-mini',
22
+ 'o1',
23
+ 'o3-mini',
24
+ 'o1-preview',
25
+ 'o1-mini',
26
+ 'gpt-4-turbo'
27
+ ]
28
+
29
+ const OPENROUTER_MODELS = [
30
+ 'openai/gpt-4o-mini',
31
+ 'openai/gpt-4o',
32
+ 'anthropic/claude-3.5-sonnet',
33
+ 'anthropic/claude-3.5-haiku',
34
+ 'google/gemini-2.5-flash',
35
+ 'meta-llama/llama-3.3-70b-instruct',
36
+ 'mistralai/mistral-large'
37
+ ]
38
+
39
+ const DEEPSEEK_MODELS = [
40
+ 'deepseek-v4-flash',
41
+ 'deepseek-v4-pro',
42
+ 'deepseek-chat',
43
+ 'deepseek-reasoner'
44
+ ]
45
+
46
+ const ANTHROPIC_MODELS = [
47
+ 'claude-3-7-sonnet-latest',
48
+ 'claude-3-5-sonnet-latest',
49
+ 'claude-3-5-haiku-latest',
50
+ 'claude-3-opus-latest'
51
+ ]
52
+
53
+ const HF_MODELS = [
54
+ 'meta-llama/Llama-3.3-70B-Instruct',
55
+ 'meta-llama/Meta-Llama-3-8B-Instruct',
56
+ 'meta-llama/Llama-3.2-3B-Instruct',
57
+ 'Qwen/Qwen2.5-72B-Instruct',
58
+ 'Qwen/Qwen2.5-Coder-32B-Instruct',
59
+ 'mistralai/Mistral-7B-Instruct-v0.3',
60
+ 'google/gemma-2-9b-it'
61
+ ]
62
+
63
+ const LOCAL_MODELS = [
64
+ 'local-model',
65
+ 'Qwen/Qwen2.5-7B-Instruct-GGUF',
66
+ 'meta-llama/Llama-3.2-3B-Instruct-GGUF',
67
+ 'lmstudio-community/gemma-2-9b-it-GGUF'
68
+ ]
69
+
70
+ const OLLAMA_MODELS = [
71
+ 'llama3:latest',
72
+ 'llama3.1:latest',
73
+ 'llama3.2:latest',
74
+ 'gemma2:latest',
75
+ 'mistral:latest',
76
+ 'phi3:latest',
77
+ 'qwen2.5:latest'
78
+ ]
79
+
80
+ interface ApprovalDetails {
81
+ title: string
82
+ body: string
83
+ reason?: string
84
+ isDangerous: boolean
85
+ }
86
+
87
+ function badge(provider: string, model: string) {
88
+ return [provider, model].filter(Boolean).join(' / ')
89
+ }
90
+
91
+ function providerLabel(provider: string) {
92
+ switch (provider) {
93
+ case 'gemini':
94
+ return 'Gemini'
95
+ case 'openai':
96
+ return 'OpenAI'
97
+ case 'openrouter':
98
+ return 'OpenRouter'
99
+ case 'deepseek':
100
+ return 'DeepSeek'
101
+ case 'anthropic':
102
+ return 'Claude'
103
+ case 'huggingface':
104
+ return 'Hugging Face'
105
+ case 'local_openai':
106
+ return 'Local OpenAI'
107
+ case 'ollama':
108
+ return 'Ollama'
109
+ default:
110
+ return provider || 'Primary provider'
111
+ }
112
+ }
113
+
114
+ function fallbackNotice(response: Pick<ChatResponse, 'provider' | 'fallbackProvider'> | null | undefined) {
115
+ if (!response?.fallbackProvider) return ''
116
+ return `${providerLabel(response.fallbackProvider)} unavailable, fell back to ${providerLabel(response.provider)}.`
117
+ }
118
+
119
+ interface AgentActivity {
120
+ label: string
121
+ target: string
122
+ kind: 'file' | 'folder' | 'search' | 'terminal' | 'tool'
123
+ state: 'active' | 'done' | 'error'
124
+ }
125
+
126
+ interface AgentActivityView {
127
+ summary: string
128
+ items: AgentActivity[]
129
+ }
130
+
131
+ function activityDetail(input: Record<string, unknown>, key: string) {
132
+ const value = input[key]
133
+ return typeof value === 'string' && value.trim() ? value : ''
134
+ }
135
+
136
+ function formatActivityTarget(value: string) {
137
+ const compact = value.replace(/^\/home\/([^/]+)/, '~')
138
+ return compact || 'workspace'
139
+ }
140
+
141
+ function activityKind(action: string, target: string): AgentActivity['kind'] {
142
+ if (['search_code', 'semantic_search', 'knowledge_search', 'web_search', 'memory_recall'].includes(action)) return 'search'
143
+ if (['run_shell', 'verify'].includes(action)) return 'terminal'
144
+ if (['list_files', 'detect_project'].includes(action)) return 'folder'
145
+ if (['read_file', 'symbols', 'read_diagnostics', 'git_diff', 'apply_patch', 'write_file', 'note_write', 'view_image'].includes(action)) return 'file'
146
+ return target.includes('/') && !/\.[^/]+$/.test(target) ? 'folder' : 'tool'
147
+ }
148
+
149
+ function describeTool(action: string, input: Record<string, unknown>): AgentActivity {
150
+ const path = activityDetail(input, 'path')
151
+ const query = activityDetail(input, 'query')
152
+ const command = activityDetail(input, 'command')
153
+ const name = activityDetail(input, 'name')
154
+ const tool = activityDetail(input, 'tool')
155
+ const target = path || query || command || name || tool || action.replaceAll('_', ' ')
156
+ const labels: Record<string, string> = {
157
+ apply_patch: 'Applying patch',
158
+ ask_user: 'Asking user',
159
+ create_plan: 'Creating plan',
160
+ detect_project: 'Detecting project',
161
+ git_branch: 'Reading branch',
162
+ git_diff: 'Reading diff',
163
+ git_log: 'Reading log',
164
+ git_status: 'Reading git status',
165
+ knowledge_search: 'Searching knowledge',
166
+ list_files: 'Listing files',
167
+ list_tests: 'Listing tests',
168
+ mcp_tool: 'Calling MCP tool',
169
+ memory_recall: 'Recalling memory',
170
+ note_write: 'Writing note',
171
+ read_diagnostics: 'Reading diagnostics',
172
+ read_file: 'Reading file',
173
+ request_user_approval: 'Requesting approval',
174
+ run_plugin: 'Running plugin',
175
+ run_shell: 'Running command',
176
+ search_code: 'Searching code',
177
+ semantic_index: 'Indexing code',
178
+ semantic_search: 'Searching code',
179
+ symbols: 'Inspecting symbols',
180
+ update_plan: 'Updating plan',
181
+ verify: 'Verifying',
182
+ view_image: 'Viewing image',
183
+ web_search: 'Searching web',
184
+ write_file: 'Writing file',
185
+ }
186
+ return {
187
+ label: labels[action] ?? 'Using tool',
188
+ target: formatActivityTarget(target),
189
+ kind: activityKind(action, target),
190
+ state: 'active',
191
+ }
192
+ }
193
+
194
+ function activitySummary(items: AgentActivity[]) {
195
+ const files = new Set<string>()
196
+ const folders = new Set<string>()
197
+ for (const item of items) {
198
+ if (item.kind === 'file') files.add(item.target)
199
+ if (item.kind === 'folder') folders.add(item.target)
200
+ }
201
+ const parts = [
202
+ files.size ? `${files.size} ${files.size === 1 ? 'file' : 'files'}` : '',
203
+ folders.size ? `${folders.size} ${folders.size === 1 ? 'folder' : 'folders'}` : '',
204
+ ].filter(Boolean)
205
+ return parts.length ? `Exploring ${parts.join(', ')}` : 'Working through task'
206
+ }
207
+
208
+ function activitiesFrom(progress: AgentProgress[]): AgentActivityView {
209
+ const activities: AgentActivity[] = []
210
+ for (const event of progress) {
211
+ if (event.type === 'ToolStart') {
212
+ activities.push(describeTool(event.data.action, event.data.input))
213
+ } else if (event.type === 'ToolEnd') {
214
+ for (let index = activities.length - 1; index >= 0; index -= 1) {
215
+ if (activities[index].state !== 'active') continue
216
+ activities[index].state = event.data.result.startsWith('Error:') ? 'error' : 'done'
217
+ if (activities[index].state === 'error') activities[index].label = 'Failed'
218
+ break
219
+ }
220
+ }
221
+ }
222
+ const items = activities.slice(-12)
223
+ return { summary: activitySummary(activities), items }
224
+ }
225
+
226
+ function renderAgentActivityTable(activityView: AgentActivityView) {
227
+ return (
228
+ <div className="agent-activity-list">
229
+ <div className="agent-activity-table-head" aria-hidden="true">
230
+ <span>Tool</span>
231
+ <span />
232
+ <span>Target</span>
233
+ <span />
234
+ </div>
235
+ {activityView.items.map((activity, index) => (
236
+ <div className="agent-activity-item" data-kind={activity.kind} data-state={activity.state} key={`${index}-${activity.label}-${activity.target}`}>
237
+ <span className="agent-activity-label">{activity.label}</span>
238
+ <span className="agent-activity-icon" aria-hidden="true" />
239
+ <span className="agent-activity-text">{activity.target}</span>
240
+ <span className="agent-activity-chevron" aria-hidden="true">&gt;</span>
241
+ </div>
242
+ ))}
243
+ </div>
244
+ )
245
+ }
246
+
247
+ function renderApprovalDetails(approval: any): ApprovalDetails {
248
+ if (!approval) return { title: 'Action Pending Approval', body: 'No action details available.', isDangerous: false }
249
+ if (approval.WriteFile) return { title: 'Write File', body: `Path: ${approval.WriteFile.path}`, reason: approval.WriteFile.diff ? `Diff:\n${approval.WriteFile.diff}` : 'Writing new file content.', isDangerous: false }
250
+ if (approval.ApplyPatch) return { title: 'Apply Patch', body: `Path: ${approval.ApplyPatch.path}`, reason: approval.ApplyPatch.diff ? `Diff:\n${approval.ApplyPatch.diff}` : 'Applying code patch.', isDangerous: false }
251
+ if (approval.RunShell) return { title: 'Run Shell Command', body: approval.RunShell.command, reason: 'Executing shell commands can modify your system.', isDangerous: true }
252
+ if (approval.NoteWrite) return { title: 'Write Note', body: `Path: ${approval.NoteWrite.path}`, reason: 'Creating or updating workspace notes.', isDangerous: false }
253
+ if (approval.RunPlugin) return { title: `Run Plugin: ${approval.RunPlugin.name}`, body: approval.RunPlugin.instruction, reason: 'Executing a native plugin action.', isDangerous: false }
254
+ if (approval.McpTool) {
255
+ const { server, tool, arguments: args } = approval.McpTool
256
+ return { title: `Run MCP Tool: ${server}/${tool}`, body: typeof args === 'string' ? args : JSON.stringify(args, null, 2), reason: 'Running external MCP tool.', isDangerous: false }
257
+ }
258
+ if (approval.UserApproval) return { title: approval.UserApproval.title, body: approval.UserApproval.prompt, reason: 'The agent requested explicit approval.', isDangerous: false }
259
+ if (approval.AskUser) return { title: 'Question From Agent', body: approval.AskUser.question, reason: 'Approve to continue without a typed answer, or cancel to decline.', isDangerous: false }
260
+ return { title: 'Unknown Action', body: JSON.stringify(approval, null, 2), reason: 'Requires approval to proceed.', isDangerous: false }
261
+ }
262
+
263
+ interface ChatPanelProps {
264
+ interactions: any[]
265
+ sending: boolean
266
+ sendingMessage: string
267
+ sendingImageCount: number
268
+ streamedReply: string
269
+ streamedResponse: ChatResponse | null
270
+ agentProgress: AgentProgress[]
271
+ agentActivitySnapshots: Record<string, AgentProgress[]>
272
+ message: string
273
+ imageAttachments: Array<{ dataUri: string; name: string; previewDataUri?: string }>
274
+ documentName: string
275
+ pendingApproval: any | null
276
+ smartContext: boolean
277
+ agentMode: boolean
278
+ status: RuntimeStatus | null
279
+ chatEnd: RefObject<HTMLDivElement | null>
280
+ welcomeInteraction: any
281
+ onSubmit: (event: FormEvent<HTMLFormElement>) => void
282
+ onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
283
+ onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
284
+ onPasteImage: (clipboardData: DataTransfer) => boolean
285
+ onReadClipboardImage: () => Promise<boolean>
286
+ onSetMessage: (message: string) => void
287
+ onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
288
+ onRemoveImage: (idx: number) => void
289
+ onRemoveDocument: () => void
290
+ onStartWebSearch: () => void
291
+ onCaptureScreen: () => void
292
+ onSetSmartContext: (enabled: boolean) => void
293
+ onSetAgentMode: (enabled: boolean) => void
294
+ onSetProvider: (provider: string) => void
295
+ onApproval: (approved: boolean, autoApproveSession?: boolean) => void
296
+ onToggleMobileSidebar: () => void
297
+ settingsConfig: any
298
+ onSetModel: (model: string) => void
299
+ }
300
+
301
+ function renderFormattedMessage(text: string) {
302
+ const displayText = readableAssistantText(text)
303
+ if (!displayText) return null
304
+
305
+ const lines = displayText.split('\n')
306
+ return lines.map((line, lineIndex) => {
307
+ const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
308
+
309
+ const formatInline = (str: string) => {
310
+ const parts = str.split(/\*\*([\s\S]*?)\*\*/g)
311
+ return parts.map((part, partIndex) => {
312
+ if (partIndex % 2 === 1) {
313
+ return (
314
+ <strong key={partIndex} className="chat-bold-highlight">
315
+ {part}
316
+ </strong>
317
+ )
318
+ }
319
+ return part
320
+ })
321
+ }
322
+
323
+ if (headerMatch) {
324
+ const level = headerMatch[1].length
325
+ const content = headerMatch[2]
326
+
327
+ const style = {
328
+ fontWeight: 'bold',
329
+ display: 'block',
330
+ marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
331
+ marginBottom: '6px',
332
+ fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
333
+ color: 'var(--text-main)'
334
+ }
335
+
336
+ return (
337
+ <span key={lineIndex} style={style}>
338
+ {formatInline(content)}
339
+ </span>
340
+ )
341
+ }
342
+
343
+ return (
344
+ <Fragment key={lineIndex}>
345
+ {formatInline(line)}
346
+ {lineIndex < lines.length - 1 && '\n'}
347
+ </Fragment>
348
+ )
349
+ })
350
+ }
351
+
352
+ function readableAssistantText(text: string) {
353
+ if (typeof text !== 'string') return ''
354
+ const trimmed = text.trim()
355
+ if (!trimmed.startsWith('{')) return text
356
+ try {
357
+ const value = JSON.parse(trimmed)
358
+ if (value?.action === 'finish' && typeof value?.input?.summary === 'string' && value.input.summary.trim()) {
359
+ return value.input.summary
360
+ }
361
+ if (typeof value?.finish?.summary === 'string' && value.finish.summary.trim()) {
362
+ return value.finish.summary
363
+ }
364
+ } catch {
365
+ return text
366
+ }
367
+ return text
368
+ }
369
+
370
+ function renderSpeakerIcon(isSpeaking: boolean) {
371
+ if (isSpeaking) {
372
+ return (
373
+ <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" style={{ display: 'block' }}>
374
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
375
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
376
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
377
+ </svg>
378
+ )
379
+ }
380
+ return (
381
+ <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" style={{ display: 'block' }}>
382
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
383
+ </svg>
384
+ )
385
+ }
386
+
387
+ function cleanSpeechText(text: string) {
388
+ return readableAssistantText(text)
389
+ .replace(/\*\*([\s\S]*?)\*\*/g, '$1')
390
+ .replace(/[*_`#]/g, '')
391
+ .trim()
392
+ }
393
+
394
+ function numericSetting(value: unknown, fallback: number) {
395
+ const numeric = Number(value)
396
+ return Number.isFinite(numeric) ? numeric : fallback
397
+ }
398
+
399
+ interface DiffHunk {
400
+ oldText: string
401
+ newText: string
402
+ }
403
+
404
+ interface FileChange {
405
+ path: string
406
+ created: boolean
407
+ additions: number
408
+ deletions: number
409
+ hunks: DiffHunk[]
410
+ }
411
+
412
+ function parseFileChangesFromProgress(progress: AgentProgress[]): FileChange[] {
413
+ const changes = new Map<string, FileChange>()
414
+ let activeEdit: { action: string; path: string; created: boolean; additions: number; deletions: number; hunks: DiffHunk[] } | null = null
415
+
416
+ for (const event of progress || []) {
417
+ if (event.type === 'ToolStart') {
418
+ if (event.data.action === 'apply_patch') {
419
+ const patch = (event.data.input as any)?.patch
420
+ if (patch && typeof patch.path === 'string') {
421
+ let additions = 0
422
+ let deletions = 0
423
+ const hunksList: DiffHunk[] = []
424
+ const hunks = patch.hunks
425
+ if (Array.isArray(hunks)) {
426
+ for (const hunk of hunks) {
427
+ const oldText = hunk?.oldText || ''
428
+ const newText = hunk?.newText || ''
429
+ const oldLines = oldText ? oldText.split('\n').length : 0
430
+ const newLines = newText ? newText.split('\n').length : 0
431
+ deletions += oldLines
432
+ additions += newLines
433
+ hunksList.push({ oldText, newText })
434
+ }
435
+ }
436
+ activeEdit = {
437
+ action: 'apply_patch',
438
+ path: patch.path,
439
+ created: false,
440
+ additions,
441
+ deletions,
442
+ hunks: hunksList
443
+ }
444
+ }
445
+ } else if (event.data.action === 'write_file') {
446
+ const path = (event.data.input as any)?.path
447
+ const fileContent = (event.data.input as any)?.file_content || ''
448
+ if (typeof path === 'string') {
449
+ const additions = fileContent ? fileContent.split('\n').length : 0
450
+ activeEdit = {
451
+ action: 'write_file',
452
+ path,
453
+ created: true,
454
+ additions,
455
+ deletions: 0,
456
+ hunks: [{ oldText: '', newText: fileContent }]
457
+ }
458
+ }
459
+ } else {
460
+ activeEdit = null
461
+ }
462
+ } else if (event.type === 'ToolEnd') {
463
+ if (activeEdit && (event.data.action === 'apply_patch' || event.data.action === 'write_file')) {
464
+ const isError = typeof event.data.result === 'string' && event.data.result.startsWith('Error:')
465
+ if (!isError) {
466
+ try {
467
+ const applied = JSON.parse(event.data.result)
468
+ const appliedPaths = Array.isArray(applied) ? applied.map(item => item?.path).filter(Boolean) : [activeEdit.path]
469
+
470
+ for (const path of appliedPaths) {
471
+ const existing = changes.get(path)
472
+ if (existing) {
473
+ existing.additions += activeEdit.additions
474
+ existing.deletions += activeEdit.deletions
475
+ existing.hunks.push(...activeEdit.hunks)
476
+ } else {
477
+ changes.set(path, {
478
+ path,
479
+ created: activeEdit.created,
480
+ additions: activeEdit.additions,
481
+ deletions: activeEdit.deletions,
482
+ hunks: [...activeEdit.hunks]
483
+ })
484
+ }
485
+ }
486
+ } catch (e) {
487
+ const path = activeEdit.path
488
+ const existing = changes.get(path)
489
+ if (existing) {
490
+ existing.additions += activeEdit.additions
491
+ existing.deletions += activeEdit.deletions
492
+ existing.hunks.push(...activeEdit.hunks)
493
+ } else {
494
+ changes.set(path, {
495
+ path,
496
+ created: activeEdit.created,
497
+ additions: activeEdit.additions,
498
+ deletions: activeEdit.deletions,
499
+ hunks: [...activeEdit.hunks]
500
+ })
501
+ }
502
+ }
503
+ }
504
+ }
505
+ activeEdit = null
506
+ }
507
+ }
508
+
509
+ return Array.from(changes.values())
510
+ }
511
+
512
+ export default function ChatPanel({
513
+ interactions,
514
+ sending,
515
+ sendingMessage,
516
+ sendingImageCount,
517
+ streamedReply,
518
+ streamedResponse,
519
+ agentProgress,
520
+ agentActivitySnapshots,
521
+ message,
522
+ imageAttachments,
523
+ documentName,
524
+ pendingApproval,
525
+ smartContext,
526
+ agentMode,
527
+ status,
528
+ chatEnd,
529
+ welcomeInteraction,
530
+ onSubmit,
531
+ onSelectImage,
532
+ onSelectDocument,
533
+ onPasteImage,
534
+ onReadClipboardImage,
535
+ onSetMessage,
536
+ onSendVoiceMessage,
537
+ onRemoveImage,
538
+ onRemoveDocument,
539
+ onStartWebSearch,
540
+ onCaptureScreen,
541
+ onSetSmartContext,
542
+ onSetAgentMode,
543
+ onSetProvider,
544
+ onApproval,
545
+ onToggleMobileSidebar,
546
+ settingsConfig,
547
+ onSetModel,
548
+ }: ChatPanelProps) {
549
+ const agentActivities = activitiesFrom(agentProgress)
550
+ const activeFallbackNotice = fallbackNotice(streamedResponse)
551
+ const [openActivityIds, setOpenActivityIds] = useState<Record<string, boolean>>({})
552
+ const [openReviewIds, setOpenReviewIds] = useState<Record<string, boolean>>({})
553
+ const [openFileDiffs, setOpenFileDiffs] = useState<Record<string, boolean>>({})
554
+ const [toolMenuOpen, setToolMenuOpen] = useState(false)
555
+ const toolMenuRef = useRef<HTMLDivElement | null>(null)
556
+ const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
557
+ const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
558
+
559
+ const getAvailableModels = (provider: string) => {
560
+ switch (provider) {
561
+ case 'gemini':
562
+ return GEMINI_MODELS
563
+ case 'openai':
564
+ return OPENAI_MODELS
565
+ case 'openrouter':
566
+ return OPENROUTER_MODELS
567
+ case 'deepseek':
568
+ return DEEPSEEK_MODELS
569
+ case 'anthropic':
570
+ return ANTHROPIC_MODELS
571
+ case 'huggingface':
572
+ return HF_MODELS
573
+ case 'local_openai':
574
+ return LOCAL_MODELS
575
+ case 'ollama':
576
+ return OLLAMA_MODELS
577
+ default:
578
+ return []
579
+ }
580
+ }
581
+
582
+ const activeProvider = status?.activeProvider ?? ''
583
+ const availableModels = getAvailableModels(activeProvider)
584
+
585
+ const getActiveModel = (provider: string) => {
586
+ if (!settingsConfig) return ''
587
+ switch (provider) {
588
+ case 'gemini':
589
+ return settingsConfig.geminiModel
590
+ case 'openai':
591
+ return settingsConfig.openaiModel
592
+ case 'openrouter':
593
+ return settingsConfig.openrouterModel
594
+ case 'deepseek':
595
+ return settingsConfig.deepseekModel
596
+ case 'anthropic':
597
+ return settingsConfig.anthropicModel
598
+ case 'huggingface':
599
+ return settingsConfig.hfModel
600
+ case 'local_openai':
601
+ return settingsConfig.localModelName
602
+ case 'ollama':
603
+ return settingsConfig.ollamaModel
604
+ default:
605
+ return ''
606
+ }
607
+ }
608
+ const activeModel = getActiveModel(activeProvider)
609
+
610
+ const [isRecording, setIsRecording] = useState(false)
611
+ const [voiceMode, setVoiceMode] = useState(false)
612
+ const [voiceTranscript, setVoiceTranscript] = useState('')
613
+ const [voiceAwaitingResponse, setVoiceAwaitingResponse] = useState(false)
614
+ const [speakingText, setSpeakingText] = useState<string | null>(null)
615
+ const recognitionRef = useRef<SpeechRecognition | null>(null)
616
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null)
617
+ const mediaStreamRef = useRef<MediaStream | null>(null)
618
+ const audioContextRef = useRef<AudioContext | null>(null)
619
+ const vadTimerRef = useRef<number | null>(null)
620
+ const audioRef = useRef<HTMLAudioElement | null>(null)
621
+ const speechRunRef = useRef(0)
622
+ const historyReadyRef = useRef(false)
623
+ const submittedDuringSessionRef = useRef(false)
624
+ const lastAutoSpokenIdRef = useRef<number | string | null>(null)
625
+ const voiceModeRef = useRef(false)
626
+ const sendingRef = useRef(false)
627
+ const voiceAwaitingResponseRef = useRef(false)
628
+ const speakingRef = useRef<string | null>(null)
629
+ const restartTimerRef = useRef<number | null>(null)
630
+ const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
631
+ const voiceStatusLabel = voiceStatus === 'speaking' ? 'กำลังตอบ' : voiceStatus === 'thinking' ? 'กำลังคิด' : voiceStatus === 'listening' ? 'กำลังฟัง' : 'พร้อมฟัง'
632
+
633
+ const clearRestartTimer = () => {
634
+ if (restartTimerRef.current === null) return
635
+ window.clearTimeout(restartTimerRef.current)
636
+ restartTimerRef.current = null
637
+ }
638
+ const clearVadTimer = () => {
639
+ if (vadTimerRef.current === null) return
640
+ window.clearInterval(vadTimerRef.current)
641
+ vadTimerRef.current = null
642
+ }
643
+ const stopRecognition = () => {
644
+ clearRestartTimer()
645
+ recognitionRef.current?.stop()
646
+ recognitionRef.current = null
647
+ clearVadTimer()
648
+ if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop()
649
+ mediaRecorderRef.current = null
650
+ mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
651
+ mediaStreamRef.current = null
652
+ audioContextRef.current?.close().catch(() => {})
653
+ audioContextRef.current = null
654
+ setIsRecording(false)
655
+ }
656
+ const scheduleVoiceListen = (delayMs = 350) => {
657
+ clearRestartTimer()
658
+ if (!voiceModeRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
659
+ restartTimerRef.current = window.setTimeout(() => {
660
+ restartTimerRef.current = null
661
+ startRecognition(true)
662
+ }, delayMs)
663
+ }
664
+ const cancelSpeech = () => {
665
+ speechRunRef.current += 1
666
+ audioRef.current?.pause()
667
+ audioRef.current = null
668
+ speakingRef.current = null
669
+ if (typeof window !== 'undefined' && window.speechSynthesis) window.speechSynthesis.cancel()
670
+ setSpeakingText(null)
671
+ }
672
+ const speakNative = (text: string, displayText: string) => {
673
+ if (typeof window === 'undefined' || !window.speechSynthesis) {
674
+ setSpeakingText(null)
675
+ speakingRef.current = null
676
+ scheduleVoiceListen(900)
677
+ return
678
+ }
679
+ const hasThai = /[\u0e00-\u0e7f]/.test(text)
680
+ const utterance = new SpeechSynthesisUtterance(text)
681
+ utterance.lang = hasThai ? 'th-TH' : 'en-US'
682
+ utterance.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
683
+ utterance.rate = Math.max(0.1, Math.min(10, numericSetting(settingsConfig?.ttsSpeed, 1)))
684
+ utterance.pitch = Math.max(0, Math.min(2, numericSetting(settingsConfig?.ttsPitch, 1)))
685
+ const voice = window.speechSynthesis.getVoices().find((item) => item.lang.startsWith(hasThai ? 'th' : 'en'))
686
+ if (voice) utterance.voice = voice
687
+ const finishSpeech = () => {
688
+ setSpeakingText((current) => (current === displayText ? null : current))
689
+ speakingRef.current = null
690
+ scheduleVoiceListen(900)
691
+ }
692
+ utterance.onend = finishSpeech
693
+ utterance.onerror = finishSpeech
694
+ speakingRef.current = displayText
695
+ setSpeakingText(displayText)
696
+ window.speechSynthesis.speak(utterance)
697
+ }
698
+ const playGoogleTts = async (text: string, displayText: string, runId: number) => {
699
+ const chunks = await getTtsUrls(text)
700
+ if (chunks.length === 0) throw new Error('No TTS URLs available')
701
+ for (const chunk of chunks) {
702
+ if (speechRunRef.current !== runId) return
703
+ await new Promise<void>((resolve, reject) => {
704
+ const audio = new Audio(chunk.url)
705
+ audio.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
706
+ audio.playbackRate = Math.max(0.25, Math.min(4, numericSetting(settingsConfig?.ttsSpeed, 1)))
707
+ audioRef.current = audio
708
+ audio.onended = () => resolve()
709
+ audio.onpause = () => resolve()
710
+ audio.onerror = () => reject(new Error('Google TTS playback failed'))
711
+ audio.play().catch(reject)
712
+ })
713
+ }
714
+ if (speechRunRef.current === runId) {
715
+ setSpeakingText((current) => (current === displayText ? null : current))
716
+ speakingRef.current = null
717
+ scheduleVoiceListen()
718
+ }
719
+ }
720
+ const speak = (text: string) => {
721
+ const speechText = cleanSpeechText(text)
722
+ if (!speechText) return
723
+ if (speakingText === text) {
724
+ cancelSpeech()
725
+ return
726
+ }
727
+
728
+ cancelSpeech()
729
+ const runId = speechRunRef.current
730
+ speakingRef.current = text
731
+ setSpeakingText(text)
732
+ if (settingsConfig?.ttsProvider === 'google') {
733
+ playGoogleTts(speechText, text, runId).catch((error) => {
734
+ console.warn('Google TTS failed, falling back to native speech synthesis:', error)
735
+ if (speechRunRef.current === runId) speakNative(speechText, text)
736
+ })
737
+ return
738
+ }
739
+ speakNative(speechText, text)
740
+ }
741
+ const blobToDataUri = (blob: Blob) => new Promise<string>((resolve, reject) => {
742
+ const reader = new FileReader()
743
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read audio recording'))
744
+ reader.onload = () => resolve(String(reader.result))
745
+ reader.readAsDataURL(blob)
746
+ })
747
+ const preferredAudioMimeType = () => {
748
+ const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']
749
+ return candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? ''
750
+ }
751
+ const startAudioRecording = async (autoSend: boolean) => {
752
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
753
+ alert('ขออภัย ระบบนี้ไม่สามารถเข้าถึงไมค์หรืออัดเสียงในเบราว์เซอร์นี้ได้')
754
+ voiceModeRef.current = false
755
+ setVoiceMode(false)
756
+ return
757
+ }
758
+
759
+ try {
760
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
761
+ const mimeType = preferredAudioMimeType()
762
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
763
+ const chunks: Blob[] = []
764
+ let heardVoice = false
765
+ let quietSince = 0
766
+ const startedAt = Date.now()
767
+ const audioContext = new AudioContext()
768
+ const source = audioContext.createMediaStreamSource(stream)
769
+ const analyser = audioContext.createAnalyser()
770
+ const samples = new Uint8Array(analyser.fftSize)
771
+ source.connect(analyser)
772
+
773
+ mediaStreamRef.current = stream
774
+ mediaRecorderRef.current = recorder
775
+ audioContextRef.current = audioContext
776
+ setVoiceTranscript('กำลังฟังเสียงจากไมค์')
777
+
778
+ recorder.ondataavailable = (event) => {
779
+ if (event.data.size > 0) chunks.push(event.data)
780
+ }
781
+ recorder.onstop = async () => {
782
+ clearVadTimer()
783
+ mediaRecorderRef.current = null
784
+ mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
785
+ mediaStreamRef.current = null
786
+ audioContextRef.current?.close().catch(() => {})
787
+ audioContextRef.current = null
788
+ setIsRecording(false)
789
+ if (!autoSend || !voiceModeRef.current || chunks.length === 0) return
790
+ if (!heardVoice) {
791
+ setVoiceTranscript('ยังไม่ได้ยินเสียงพูด')
792
+ scheduleVoiceListen(700)
793
+ return
794
+ }
795
+ const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
796
+ const audioDataUri = await blobToDataUri(blob)
797
+ setVoiceTranscript('ส่งเสียงให้ AI แล้ว')
798
+ voiceAwaitingResponseRef.current = true
799
+ setVoiceAwaitingResponse(true)
800
+ onSendVoiceMessage('', audioDataUri)
801
+ .catch((error) => console.error('Voice audio message failed', error))
802
+ .finally(() => {
803
+ voiceAwaitingResponseRef.current = false
804
+ setVoiceAwaitingResponse(false)
805
+ scheduleVoiceListen()
806
+ })
807
+ }
808
+
809
+ recorder.start()
810
+ setIsRecording(true)
811
+ vadTimerRef.current = window.setInterval(() => {
812
+ analyser.getByteTimeDomainData(samples)
813
+ let peak = 0
814
+ for (const sample of samples) {
815
+ peak = Math.max(peak, Math.abs(sample - 128))
816
+ }
817
+ const now = Date.now()
818
+ if (peak > 12) {
819
+ heardVoice = true
820
+ quietSince = 0
821
+ setVoiceTranscript('กำลังฟัง...')
822
+ } else if (heardVoice) {
823
+ quietSince = quietSince || now
824
+ }
825
+ const silenceElapsed = quietSince ? now - quietSince : 0
826
+ const totalElapsed = now - startedAt
827
+ if ((heardVoice && silenceElapsed > 1300) || totalElapsed > 12000) {
828
+ recorder.stop()
829
+ }
830
+ }, 120)
831
+ } catch (error) {
832
+ console.error('Failed to record microphone audio', error)
833
+ setIsRecording(false)
834
+ voiceModeRef.current = false
835
+ setVoiceMode(false)
836
+ alert('เปิดไมค์ไม่สำเร็จ กรุณาตรวจสิทธิ์ microphone ของเบราว์เซอร์')
837
+ }
838
+ }
839
+ const startRecognition = (autoSend: boolean) => {
840
+ if (recognitionRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
841
+
842
+ const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition
843
+ if (!SpeechRecognitionApi) {
844
+ startAudioRecording(autoSend)
845
+ return
846
+ }
847
+
848
+ let sentTranscript = false
849
+
850
+ try {
851
+ const recognition = new SpeechRecognitionApi()
852
+ recognition.continuous = false
853
+ recognition.interimResults = true
854
+ recognition.lang = settingsConfig?.language === 'en' ? 'en-US' : 'th-TH'
855
+ recognition.onstart = () => setIsRecording(true)
856
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
857
+ let interimText = ''
858
+ let finalText = ''
859
+ for (let index = event.resultIndex; index < event.results.length; index += 1) {
860
+ const transcript = event.results[index]?.[0]?.transcript ?? ''
861
+ if (event.results[index]?.isFinal) finalText += transcript
862
+ else interimText += transcript
863
+ }
864
+ const displayText = (finalText || interimText).trim()
865
+ if (displayText) setVoiceTranscript(displayText)
866
+ const resultText = finalText.trim()
867
+ if (!resultText) return
868
+ sentTranscript = true
869
+ recognition.stop()
870
+ setVoiceTranscript(resultText)
871
+ if (autoSend) {
872
+ voiceAwaitingResponseRef.current = true
873
+ setVoiceAwaitingResponse(true)
874
+ onSendVoiceMessage(resultText)
875
+ .catch((error) => console.error('Voice message failed', error))
876
+ .finally(() => {
877
+ voiceAwaitingResponseRef.current = false
878
+ setVoiceAwaitingResponse(false)
879
+ scheduleVoiceListen()
880
+ })
881
+ } else {
882
+ onSetMessage(message.trim() ? `${message.trimEnd()} ${resultText}` : resultText)
883
+ }
884
+ }
885
+ recognition.onerror = (event: Event) => {
886
+ console.error('Speech recognition error', event)
887
+ setIsRecording(false)
888
+ }
889
+ recognition.onend = () => {
890
+ recognitionRef.current = null
891
+ setIsRecording(false)
892
+ if (autoSend && voiceModeRef.current && !sentTranscript) scheduleVoiceListen()
893
+ }
894
+ recognitionRef.current = recognition
895
+ recognition.start()
896
+ } catch (error) {
897
+ console.error('Failed to start speech recognition', error)
898
+ recognitionRef.current = null
899
+ setIsRecording(false)
900
+ }
901
+ }
902
+ const toggleRecording = () => {
903
+ if (voiceMode) {
904
+ voiceModeRef.current = false
905
+ voiceAwaitingResponseRef.current = false
906
+ setVoiceMode(false)
907
+ setVoiceAwaitingResponse(false)
908
+ setVoiceTranscript('')
909
+ stopRecognition()
910
+ cancelSpeech()
911
+ return
912
+ }
913
+
914
+ voiceModeRef.current = true
915
+ setVoiceMode(true)
916
+ setVoiceAwaitingResponse(false)
917
+ setVoiceTranscript('')
918
+ startRecognition(true)
919
+ }
920
+
921
+ useEffect(() => {
922
+ return () => {
923
+ clearRestartTimer()
924
+ cancelSpeech()
925
+ stopRecognition()
926
+ }
927
+ }, [])
928
+ useEffect(() => {
929
+ voiceModeRef.current = voiceMode
930
+ if (!voiceMode) {
931
+ clearRestartTimer()
932
+ setVoiceTranscript('')
933
+ }
934
+ }, [voiceMode])
935
+ useEffect(() => {
936
+ sendingRef.current = sending
937
+ }, [sending])
938
+ useEffect(() => {
939
+ voiceAwaitingResponseRef.current = voiceAwaitingResponse
940
+ }, [voiceAwaitingResponse])
941
+ useEffect(() => {
942
+ speakingRef.current = speakingText
943
+ }, [speakingText])
944
+ useEffect(() => {
945
+ if (!voiceMode || sending || voiceAwaitingResponse || speakingText || isRecording) return
946
+ scheduleVoiceListen()
947
+ }, [voiceMode, sending, voiceAwaitingResponse, speakingText, isRecording])
948
+ useEffect(() => {
949
+ if (sending) submittedDuringSessionRef.current = true
950
+ }, [sending])
951
+ useEffect(() => {
952
+ if (interactions.length === 0) return
953
+ const latest = interactions[interactions.length - 1]
954
+ if (!historyReadyRef.current) {
955
+ historyReadyRef.current = true
956
+ if (!submittedDuringSessionRef.current) {
957
+ lastAutoSpokenIdRef.current = latest?.id ?? null
958
+ return
959
+ }
960
+ }
961
+ if (sending) return
962
+ if (!settingsConfig?.enableVoiceReply && !voiceMode) {
963
+ lastAutoSpokenIdRef.current = latest?.id ?? null
964
+ return
965
+ }
966
+ if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
967
+ lastAutoSpokenIdRef.current = latest.id
968
+ speak(latest.aiText)
969
+ }, [interactions, sending, settingsConfig?.enableVoiceReply])
970
+
971
+ // Drag and Drop Zone Overlay
972
+ const [isDragging, setIsDragging] = useState(false)
973
+ const dragCounter = useRef(0)
974
+
975
+ const handleDragEnter = (e: DragEvent<HTMLElement>) => {
976
+ e.preventDefault()
977
+ if (e.dataTransfer?.types?.includes('Files')) {
978
+ dragCounter.current++
979
+ setIsDragging(true)
980
+ }
981
+ }
982
+
983
+ const handleDragLeave = (e: DragEvent<HTMLElement>) => {
984
+ e.preventDefault()
985
+ if (e.dataTransfer?.types?.includes('Files')) {
986
+ dragCounter.current--
987
+ if (dragCounter.current === 0) {
988
+ setIsDragging(false)
989
+ }
990
+ }
991
+ }
992
+
993
+ const handleDragOver = (e: DragEvent<HTMLElement>) => {
994
+ e.preventDefault()
995
+ }
996
+
997
+ const handleDrop = (e: DragEvent<HTMLElement>) => {
998
+ e.preventDefault()
999
+ dragCounter.current = 0
1000
+ setIsDragging(false)
1001
+
1002
+ const files = e.dataTransfer?.files
1003
+ if (files && files.length > 0) {
1004
+ const file = files[0]
1005
+ if (file.type.startsWith('image/')) {
1006
+ const input = document.getElementById('vision-file-input') as HTMLInputElement | null
1007
+ if (input) {
1008
+ const dt = new DataTransfer()
1009
+ dt.items.add(file)
1010
+ input.files = dt.files
1011
+ const event = { target: input } as ChangeEvent<HTMLInputElement>
1012
+ onSelectImage(event)
1013
+ }
1014
+ } else if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
1015
+ const input = document.getElementById('document-file-input') as HTMLInputElement | null
1016
+ if (input) {
1017
+ const dt = new DataTransfer()
1018
+ dt.items.add(file)
1019
+ input.files = dt.files
1020
+ const event = { target: input } as ChangeEvent<HTMLInputElement>
1021
+ onSelectDocument(event)
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
1027
+ if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
1028
+ event.preventDefault()
1029
+ event.currentTarget.form?.requestSubmit()
1030
+ }
1031
+ const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
1032
+ submitOnEnter(event)
1033
+ }
1034
+ const resizeInput = (element: HTMLTextAreaElement) => {
1035
+ element.style.height = 'auto'
1036
+ element.style.height = `${Math.min(element.scrollHeight, 120)}px`
1037
+ }
1038
+ useEffect(() => {
1039
+ if (!toolMenuOpen) return
1040
+ const closeMenu = (event: MouseEvent) => {
1041
+ if (toolMenuRef.current?.contains(event.target as Node)) return
1042
+ setToolMenuOpen(false)
1043
+ }
1044
+ window.addEventListener('mousedown', closeMenu)
1045
+ return () => window.removeEventListener('mousedown', closeMenu)
1046
+ }, [toolMenuOpen])
1047
+ useEffect(() => {
1048
+ const handleWindowPaste = (event: globalThis.ClipboardEvent) => {
1049
+ if (!event.clipboardData) return
1050
+ if (onPasteImage(event.clipboardData)) {
1051
+ event.preventDefault()
1052
+ event.stopPropagation()
1053
+ } else {
1054
+ window.setTimeout(() => void onReadClipboardImage(), 0)
1055
+ }
1056
+ }
1057
+ window.addEventListener('paste', handleWindowPaste, true)
1058
+ return () => window.removeEventListener('paste', handleWindowPaste, true)
1059
+ }, [onPasteImage, onReadClipboardImage])
1060
+ useEffect(() => {
1061
+ const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1062
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'v') return
1063
+ window.setTimeout(() => void onReadClipboardImage(), 0)
1064
+ }
1065
+ window.addEventListener('keydown', handleWindowKeyDown, true)
1066
+ return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
1067
+ }, [onReadClipboardImage])
1068
+ useEffect(() => {
1069
+ if (message) return
1070
+ const input = document.getElementById('chat-input') as HTMLTextAreaElement | null
1071
+ if (input) input.style.height = ''
1072
+ }, [message])
1073
+
1074
+ const openImagePicker = () => {
1075
+ setToolMenuOpen(false)
1076
+ document.getElementById('vision-file-input')?.click()
1077
+ }
1078
+ const openDocumentPicker = () => {
1079
+ setToolMenuOpen(false)
1080
+ document.getElementById('document-file-input')?.click()
1081
+ }
1082
+ const startWebSearch = () => {
1083
+ setToolMenuOpen(false)
1084
+ onStartWebSearch()
1085
+ }
1086
+ const isEmptyChat = interactions.length === 0 && !sending && !pendingApproval
1087
+ const renderCompletedActivity = (interaction: any) => {
1088
+ const interactionId = String(interaction.id)
1089
+ const activityView = activitiesFrom(agentActivitySnapshots[interactionId] ?? [])
1090
+ if (activityView.items.length === 0) return null
1091
+ const isOpen = Boolean(openActivityIds[interactionId])
1092
+ return (
1093
+ <div className="agent-activity-history">
1094
+ <button
1095
+ type="button"
1096
+ className="agent-activity-toggle"
1097
+ aria-expanded={isOpen}
1098
+ onClick={() => setOpenActivityIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
1099
+ >
1100
+ <span>{activityView.summary}</span>
1101
+ <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1102
+ </button>
1103
+ {isOpen && (
1104
+ <div className="agent-activity-card agent-activity-card-history">
1105
+ {renderAgentActivityTable(activityView)}
1106
+ </div>
1107
+ )}
1108
+ </div>
1109
+ )
1110
+ }
1111
+
1112
+ const renderFileChanges = (interaction: any) => {
1113
+ const interactionId = String(interaction.id)
1114
+ const progress = agentActivitySnapshots[interactionId] ?? []
1115
+ const changes = parseFileChangesFromProgress(progress)
1116
+ if (changes.length === 0) return null
1117
+
1118
+ const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0)
1119
+ const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0)
1120
+ const isOpen = Boolean(openReviewIds[interactionId])
1121
+
1122
+ return (
1123
+ <div className="file-changes-summary-container" style={{ marginBottom: '8px' }}>
1124
+ <button
1125
+ type="button"
1126
+ className="agent-activity-toggle"
1127
+ aria-expanded={isOpen}
1128
+ onClick={() => setOpenReviewIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
1129
+ style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontWeight: 500 }}
1130
+ >
1131
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '2px' }}>
1132
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
1133
+ <polyline points="22 4 12 14.01 9 11.01" />
1134
+ </svg>
1135
+ <span>
1136
+ {changes.length} {changes.length === 1 ? 'file' : 'files'} changed
1137
+ {totalAdditions > 0 && <span style={{ color: '#10b981', marginLeft: '6px' }}>+{totalAdditions}</span>}
1138
+ {totalDeletions > 0 && <span style={{ color: '#ef4444', marginLeft: '4px' }}>-{totalDeletions}</span>}
1139
+ </span>
1140
+ <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1141
+ </button>
1142
+
1143
+ {isOpen && (
1144
+ <div className="agent-activity-card" style={{ border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: '8px', padding: '10px', background: 'rgba(15, 23, 42, 0.6)', marginTop: '4px' }}>
1145
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
1146
+ {changes.map((change) => {
1147
+ const fileKey = `${interactionId}-${change.path}`
1148
+ const isDiffOpen = Boolean(openFileDiffs[fileKey])
1149
+ const fileName = change.path.split('/').pop() || change.path
1150
+ const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : ''
1151
+
1152
+ return (
1153
+ <div key={change.path} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)', paddingBottom: '4px' }}>
1154
+ <div
1155
+ onClick={() => setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))}
1156
+ style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }}
1157
+ >
1158
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1159
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1160
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
1161
+ <polyline points="14 2 14 8 20 8" />
1162
+ </svg>
1163
+ <span style={{ fontSize: '0.82rem', fontWeight: 600, color: change.created ? '#10b981' : '#cbd5e1' }}>
1164
+ {fileName}
1165
+ {dirPath && <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 400, marginLeft: '6px' }}>{dirPath}</span>}
1166
+ {change.created && <span style={{ fontSize: '0.7rem', color: '#10b981', marginLeft: '6px', padding: '1px 4px', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '3px', background: 'rgba(16, 185, 129, 0.1)' }}>new</span>}
1167
+ </span>
1168
+ </div>
1169
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.76rem' }}>
1170
+ {change.additions > 0 && <span style={{ color: '#10b981' }}>+{change.additions}</span>}
1171
+ {change.deletions > 0 && <span style={{ color: '#ef4444' }}>-{change.deletions}</span>}
1172
+ <span style={{ color: '#64748b', transform: isDiffOpen ? 'rotate(90deg)' : 'none', display: 'inline-block', transition: 'transform 0.15s' }}>&gt;</span>
1173
+ </div>
1174
+ </div>
1175
+
1176
+ {isDiffOpen && (
1177
+ <div style={{ marginTop: '6px', background: '#0b0f19', borderRadius: '6px', padding: '8px', border: '1px solid rgba(255, 255, 255, 0.08)', overflowX: 'auto', maxHeight: '300px' }}>
1178
+ {change.hunks.map((hunk, hIdx) => (
1179
+ <div key={hIdx} style={{ fontSize: '0.74rem', fontFamily: 'monospace', lineHeight: '1.4', marginBottom: hIdx < change.hunks.length - 1 ? '10px' : 0 }}>
1180
+ {hunk.oldText && (
1181
+ <div style={{ background: 'rgba(239, 68, 68, 0.12)', borderLeft: '3px solid #ef4444', padding: '4px 6px', color: '#fca5a5', whiteSpace: 'pre-wrap' }}>
1182
+ {hunk.oldText.split('\n').map((line, lIdx) => (
1183
+ <div key={lIdx}>- {line}</div>
1184
+ ))}
1185
+ </div>
1186
+ )}
1187
+ {hunk.newText && (
1188
+ <div style={{ background: 'rgba(16, 185, 129, 0.12)', borderLeft: '3px solid #10b981', padding: '4px 6px', color: '#a7f3d0', whiteSpace: 'pre-wrap' }}>
1189
+ {hunk.newText.split('\n').map((line, lIdx) => (
1190
+ <div key={lIdx}>+ {line}</div>
1191
+ ))}
1192
+ </div>
1193
+ )}
1194
+ </div>
1195
+ ))}
1196
+ </div>
1197
+ )}
1198
+ </div>
1199
+ )
1200
+ })}
1201
+ </div>
1202
+ </div>
1203
+ )}
1204
+ </div>
1205
+ )
1206
+ }
1207
+
1208
+ const renderActiveFileChanges = () => {
1209
+ const changes = parseFileChangesFromProgress(agentProgress)
1210
+ if (changes.length === 0) return null
1211
+
1212
+ const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0)
1213
+ const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0)
1214
+ const isOpen = Boolean(openReviewIds['active-run'])
1215
+
1216
+ return (
1217
+ <div className="message ai-message agent-activity-message" style={{ marginTop: '4px', marginBottom: '8px' }}>
1218
+ <div className="agent-activity-card" style={{ border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '8px', padding: '10px', background: 'rgba(15, 23, 42, 0.6)' }}>
1219
+ <button
1220
+ type="button"
1221
+ className="agent-activity-toggle"
1222
+ aria-expanded={isOpen}
1223
+ onClick={() => setOpenReviewIds((current) => ({ ...current, 'active-run': !current['active-run'] }))}
1224
+ style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontWeight: 500, border: 0, background: 'transparent', padding: 0 }}
1225
+ >
1226
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '2px' }}>
1227
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
1228
+ <polyline points="22 4 12 14.01 9 11.01" />
1229
+ </svg>
1230
+ <span>
1231
+ {changes.length} {changes.length === 1 ? 'file' : 'files'} changed in this run
1232
+ {totalAdditions > 0 && <span style={{ color: '#10b981', marginLeft: '6px' }}>+{totalAdditions}</span>}
1233
+ {totalDeletions > 0 && <span style={{ color: '#ef4444', marginLeft: '4px' }}>-{totalDeletions}</span>}
1234
+ </span>
1235
+ <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1236
+ </button>
1237
+
1238
+ {isOpen && (
1239
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '8px' }}>
1240
+ {changes.map((change) => {
1241
+ const fileKey = `active-${change.path}`
1242
+ const isDiffOpen = Boolean(openFileDiffs[fileKey])
1243
+ const fileName = change.path.split('/').pop() || change.path
1244
+ const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : ''
1245
+
1246
+ return (
1247
+ <div key={change.path} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)', paddingBottom: '4px' }}>
1248
+ <div
1249
+ onClick={() => setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))}
1250
+ style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }}
1251
+ >
1252
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1253
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1254
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
1255
+ <polyline points="14 2 14 8 20 8" />
1256
+ </svg>
1257
+ <span style={{ fontSize: '0.82rem', fontWeight: 600, color: change.created ? '#10b981' : '#cbd5e1' }}>
1258
+ {fileName}
1259
+ {dirPath && <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 400, marginLeft: '6px' }}>{dirPath}</span>}
1260
+ {change.created && <span style={{ fontSize: '0.7rem', color: '#10b981', marginLeft: '6px', padding: '1px 4px', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '3px', background: 'rgba(16, 185, 129, 0.1)' }}>new</span>}
1261
+ </span>
1262
+ </div>
1263
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.76rem' }}>
1264
+ {change.additions > 0 && <span style={{ color: '#10b981' }}>+{change.additions}</span>}
1265
+ {change.deletions > 0 && <span style={{ color: '#ef4444' }}>-{change.deletions}</span>}
1266
+ <span style={{ color: '#64748b', transform: isDiffOpen ? 'rotate(90deg)' : 'none', display: 'inline-block', transition: 'transform 0.15s' }}>&gt;</span>
1267
+ </div>
1268
+ </div>
1269
+
1270
+ {isDiffOpen && (
1271
+ <div style={{ marginTop: '6px', background: '#0b0f19', borderRadius: '6px', padding: '8px', border: '1px solid rgba(255, 255, 255, 0.08)', overflowX: 'auto', maxHeight: '300px' }}>
1272
+ {change.hunks.map((hunk, hunkIdx) => (
1273
+ <div key={hunkIdx} style={{ fontSize: '0.74rem', fontFamily: 'monospace', lineHeight: '1.4', marginBottom: hunkIdx < change.hunks.length - 1 ? '10px' : 0 }}>
1274
+ {hunk.oldText && (
1275
+ <div style={{ background: 'rgba(239, 68, 68, 0.12)', borderLeft: '3px solid #ef4444', padding: '4px 6px', color: '#fca5a5', whiteSpace: 'pre-wrap' }}>
1276
+ {hunk.oldText.split('\n').map((line, lIdx) => (
1277
+ <div key={lIdx}>- {line}</div>
1278
+ ))}
1279
+ </div>
1280
+ )}
1281
+ {hunk.newText && (
1282
+ <div style={{ background: 'rgba(16, 185, 129, 0.12)', borderLeft: '3px solid #10b981', padding: '4px 6px', color: '#a7f3d0', whiteSpace: 'pre-wrap' }}>
1283
+ {hunk.newText.split('\n').map((line, lIdx) => (
1284
+ <div key={lIdx}>+ {line}</div>
1285
+ ))}
1286
+ </div>
1287
+ )}
1288
+ </div>
1289
+ ))}
1290
+ </div>
1291
+ )}
1292
+ </div>
1293
+ )
1294
+ })}
1295
+ </div>
1296
+ )}
1297
+ </div>
1298
+ </div>
1299
+ )
1300
+ }
1301
+
1302
+ return (
1303
+ <section
1304
+ className={`conversation-panel ${isEmptyChat ? 'is-empty' : ''}`}
1305
+ onDragEnter={handleDragEnter}
1306
+ onDragOver={handleDragOver}
1307
+ onDragLeave={handleDragLeave}
1308
+ onDrop={handleDrop}
1309
+ style={{ position: 'relative' }}
1310
+ >
1311
+ <div className="chat-header">
1312
+ <button
1313
+ className="mobile-menu-btn"
1314
+ type="button"
1315
+ onClick={onToggleMobileSidebar}
1316
+ aria-label="Toggle menu"
1317
+ >
1318
+
1319
+ </button>
1320
+ <div className="chat-header-title">
1321
+ <img src="./assets/icon.png" alt="Logo" className="chat-header-logo" />
1322
+ <span>Agent Mint</span>
1323
+ </div>
1324
+ </div>
1325
+
1326
+ {isDragging && (
1327
+ <div
1328
+ className="drag-drop-overlay"
1329
+ onDragEnter={handleDragEnter}
1330
+ onDragLeave={handleDragLeave}
1331
+ onDragOver={handleDragOver}
1332
+ onDrop={handleDrop}
1333
+ style={{
1334
+ position: 'absolute',
1335
+ top: 0,
1336
+ left: 0,
1337
+ right: 0,
1338
+ bottom: 0,
1339
+ background: 'rgba(15, 23, 42, 0.82)',
1340
+ backdropFilter: 'blur(8px)',
1341
+ border: '2px dashed var(--accent)',
1342
+ borderRadius: '16px',
1343
+ margin: '12px',
1344
+ display: 'flex',
1345
+ flexDirection: 'column',
1346
+ alignItems: 'center',
1347
+ justifyContent: 'center',
1348
+ color: 'white',
1349
+ zIndex: 1000,
1350
+ pointerEvents: 'auto',
1351
+ }}
1352
+ >
1353
+ <div style={{ fontSize: '3.5rem', marginBottom: '16px' }}>🖼️</div>
1354
+ <div style={{ fontSize: '1.25rem', fontWeight: 'bold', letterSpacing: '0.5px' }}>วางไฟล์เพื่อแนบข้อมูล</div>
1355
+ <div style={{ fontSize: '0.85rem', color: '#94a3b8', marginTop: '8px' }}>รองรับรูปภาพ (PNG, JPEG, WebP, GIF) และไฟล์ PDF</div>
1356
+ </div>
1357
+ )}
1358
+
1359
+ <div className="chat-container">
1360
+ {interactions.map((interaction) => (
1361
+ <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1362
+ {interaction.isSystemEvent ? (
1363
+ <div className="system-event" style={{ background: 'rgba(16, 185, 129, 0.06)', border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: '8px', padding: '10px 14px', color: '#a7f3d0', fontSize: '0.82rem', lineHeight: '1.45', alignSelf: 'stretch' }}>
1364
+ {interaction.userText}
1365
+ </div>
1366
+ ) : interaction.userText && (
1367
+ <div className="message user-message">
1368
+ <div className="bubble-wrapper">
1369
+ <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1370
+ <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1371
+ </div>
1372
+ </div>
1373
+ )}
1374
+ <div className="message ai-message">
1375
+ <div className="bubble-wrapper">
1376
+ {renderCompletedActivity(interaction)}
1377
+ {renderFileChanges(interaction)}
1378
+ <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1379
+ <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1380
+ <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
1381
+ {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
1382
+ <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
1383
+ <button
1384
+ type="button"
1385
+ className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
1386
+ onClick={() => speak(interaction.aiText)}
1387
+ style={{
1388
+ background: 'transparent',
1389
+ border: 'none',
1390
+ color: speakingText === interaction.aiText ? 'var(--accent)' : 'var(--text-soft)',
1391
+ cursor: 'pointer',
1392
+ fontSize: '0.85rem',
1393
+ padding: '2px',
1394
+ display: 'inline-flex',
1395
+ alignItems: 'center',
1396
+ opacity: 0.7,
1397
+ transition: 'all 0.2s',
1398
+ }}
1399
+ title={speakingText === interaction.aiText ? "หยุดอ่านออกเสียง" : "อ่านออกเสียง"}
1400
+ >
1401
+ {renderSpeakerIcon(speakingText === interaction.aiText)}
1402
+ </button>
1403
+ </div>
1404
+ </div>
1405
+ </div>
1406
+ </div>
1407
+ ))}
1408
+
1409
+ {sending && (
1410
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1411
+ <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
1412
+ {agentMode && agentActivities.items.length > 0 && (
1413
+ <div className="message ai-message agent-activity-message">
1414
+ <div className="agent-activity-card">
1415
+ <div className="agent-activity-header">
1416
+ <span>{agentActivities.summary}</span>
1417
+ <span className="agent-activity-status" data-state={pendingApproval ? 'approval' : 'active'}>
1418
+ {pendingApproval ? 'Waiting for approval' : 'Working'}
1419
+ </span>
1420
+ </div>
1421
+ {renderAgentActivityTable(agentActivities)}
1422
+ </div>
1423
+ </div>
1424
+ )}
1425
+ {renderActiveFileChanges()}
1426
+ <div className="message ai-message thinking-message">
1427
+ <div className="bubble-wrapper">
1428
+ <div className="message-bubble"><span>{streamedReply ? renderFormattedMessage(streamedReply) : 'Thinking...'}</span></div>
1429
+ {streamedResponse && (
1430
+ <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1431
+ <button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
1432
+ {activeFallbackNotice && <span className="provider-fallback-notice">{activeFallbackNotice}</span>}
1433
+ {streamedReply && (
1434
+ <button
1435
+ type="button"
1436
+ className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
1437
+ onClick={() => speak(streamedReply)}
1438
+ style={{
1439
+ background: 'transparent',
1440
+ border: 'none',
1441
+ color: speakingText === streamedReply ? 'var(--accent)' : 'var(--text-soft)',
1442
+ cursor: 'pointer',
1443
+ fontSize: '0.85rem',
1444
+ padding: '2px',
1445
+ display: 'inline-flex',
1446
+ alignItems: 'center',
1447
+ opacity: 0.7,
1448
+ transition: 'all 0.2s',
1449
+ }}
1450
+ title={speakingText === streamedReply ? "หยุดอ่านออกเสียง" : "อ่านออกเสียง"}
1451
+ >
1452
+ {renderSpeakerIcon(speakingText === streamedReply)}
1453
+ </button>
1454
+ )}
1455
+ </div>
1456
+ )}
1457
+ </div>
1458
+ </div>
1459
+ </div>
1460
+ )}
1461
+
1462
+ <div ref={chatEnd} />
1463
+ </div>
1464
+
1465
+ <div className="input-area">
1466
+ {isEmptyChat && <div className="empty-chat-prompt">Mint Agent is ready to work</div>}
1467
+ <div className="smart-context-bar">
1468
+ <div className="smart-context-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1469
+ <label className="toggle-switch">
1470
+ <input type="checkbox" checked={agentMode} onChange={(event) => onSetAgentMode(event.target.checked)} />
1471
+ <span className="slider round" />
1472
+ </label>
1473
+ <span>Agent Mode</span>
1474
+ </div>
1475
+ </div>
1476
+ {voiceMode && (
1477
+ <div className="voice-mode-bar" data-state={voiceStatus}>
1478
+ <span className="voice-mode-dot" />
1479
+ <span>{voiceStatusLabel}</span>
1480
+ {voiceTranscript && <span className="voice-mode-transcript">{voiceTranscript}</span>}
1481
+ </div>
1482
+ )}
1483
+
1484
+ <form
1485
+ id="chat-form"
1486
+ onSubmit={onSubmit}
1487
+ onPaste={(event: ClipboardEvent<HTMLElement>) => {
1488
+ if (onPasteImage(event.clipboardData)) event.preventDefault()
1489
+ }}
1490
+ >
1491
+ {(imageAttachments.length > 0 || documentName) && (
1492
+ <div className="mint-attachment">
1493
+ {imageAttachments.map((attachment, idx) => (
1494
+ <div className="mint-image-attachment" key={idx}>
1495
+ <img className="mint-image-preview" src={attachment.previewDataUri || attachment.dataUri} alt={attachment.name || 'Image attachment'} />
1496
+ <button className="mint-attachment-remove" type="button" onClick={() => onRemoveImage(idx)} aria-label="Remove image">×</button>
1497
+ </div>
1498
+ ))}
1499
+ {documentName && (
1500
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
1501
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--accent)' }}>
1502
+ <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">
1503
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1504
+ <polyline points="14 2 14 8 20 8"></polyline>
1505
+ <line x1="16" y1="13" x2="8" y2="13"></line>
1506
+ <line x1="16" y1="17" x2="8" y2="17"></line>
1507
+ </svg>
1508
+ </span>
1509
+ <span style={{ fontSize: '0.76rem', color: 'var(--text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '220px' }}>{documentName}</span>
1510
+ <button type="button" onClick={onRemoveDocument} style={{ background: 'transparent', border: 0, color: '#ef4444', cursor: 'pointer' }}>✕</button>
1511
+ </div>
1512
+ )}
1513
+ </div>
1514
+ )}
1515
+ <textarea
1516
+ id="chat-input"
1517
+ value={message}
1518
+ onChange={(event) => {
1519
+ resizeInput(event.currentTarget)
1520
+ onSetMessage(event.target.value)
1521
+ }}
1522
+ onKeyDown={handleInputKeyDown}
1523
+ placeholder="Ask anything, @ to mention, / for actions"
1524
+ rows={1}
1525
+ />
1526
+ <div className="chat-tool-menu-wrap" ref={toolMenuRef}>
1527
+ <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' }}>
1528
+ <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">
1529
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1530
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1531
+ </svg>
1532
+ </button>
1533
+ {toolMenuOpen && (
1534
+ <div className="chat-tool-menu" role="menu">
1535
+ <button type="button" role="menuitem" onClick={openImagePicker}>
1536
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1537
+ <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">
1538
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1539
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
1540
+ <polyline points="21 15 16 10 5 21"></polyline>
1541
+ </svg>
1542
+ </span>
1543
+ <span>Add image</span>
1544
+ </button>
1545
+ <button type="button" role="menuitem" onClick={openDocumentPicker}>
1546
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1547
+ <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">
1548
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1549
+ <polyline points="14 2 14 8 20 8"></polyline>
1550
+ </svg>
1551
+ </span>
1552
+ <span>Add file</span>
1553
+ </button>
1554
+ <button type="button" role="menuitem" onClick={startWebSearch}>
1555
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1556
+ <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">
1557
+ <circle cx="12" cy="12" r="10"></circle>
1558
+ <line x1="2" y1="12" x2="22" y2="12"></line>
1559
+ <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>
1560
+ </svg>
1561
+ </span>
1562
+ <span>Search web</span>
1563
+ </button>
1564
+ </div>
1565
+ )}
1566
+ </div>
1567
+ <input id="vision-file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={onSelectImage} style={{ display: 'none' }} />
1568
+ <input id="document-file-input" type="file" accept="application/pdf,.pdf" onChange={onSelectDocument} style={{ display: 'none' }} />
1569
+ <div className="chat-provider-select" style={{ display: 'flex', gap: '4px', padding: 0, background: 'transparent', border: 0, width: '100%', height: '32px' }}>
1570
+ <select
1571
+ value={status?.activeProvider ?? ''}
1572
+ onChange={(event) => onSetProvider(event.target.value)}
1573
+ style={{
1574
+ flex: 1,
1575
+ minWidth: '65px',
1576
+ height: '100%',
1577
+ padding: '0 20px 0 6px',
1578
+ background: 'transparent',
1579
+ border: 0,
1580
+ color: 'var(--text-soft)',
1581
+ fontSize: '0.78rem',
1582
+ outline: 'none',
1583
+ cursor: 'pointer',
1584
+ fontFamily: 'inherit',
1585
+ textOverflow: 'ellipsis',
1586
+ whiteSpace: 'nowrap'
1587
+ }}
1588
+ >
1589
+ {status?.availableProviders.map((provider) => {
1590
+ let displayName = provider
1591
+ if (provider === 'gemini') displayName = 'Gemini'
1592
+ else if (provider === 'openai') displayName = 'OpenAI'
1593
+ else if (provider === 'openrouter') displayName = 'OpenRouter'
1594
+ else if (provider === 'deepseek') displayName = 'DeepSeek'
1595
+ else if (provider === 'anthropic') displayName = 'Claude'
1596
+ else if (provider === 'huggingface') displayName = 'HF'
1597
+ else if (provider === 'local_openai') displayName = 'Local'
1598
+ else if (provider === 'ollama') displayName = 'Ollama'
1599
+ return <option key={provider} value={provider}>{displayName}</option>
1600
+ })}
1601
+ </select>
1602
+ {availableModels.length > 0 && (
1603
+ <select
1604
+ value={activeModel}
1605
+ onChange={(event) => onSetModel(event.target.value)}
1606
+ style={{
1607
+ flex: 1.2,
1608
+ minWidth: '85px',
1609
+ height: '100%',
1610
+ padding: '0 20px 0 6px',
1611
+ background: 'transparent',
1612
+ border: 0,
1613
+ color: 'var(--text-soft)',
1614
+ fontSize: '0.78rem',
1615
+ outline: 'none',
1616
+ cursor: 'pointer',
1617
+ fontFamily: 'inherit',
1618
+ textOverflow: 'ellipsis',
1619
+ whiteSpace: 'nowrap'
1620
+ }}
1621
+ >
1622
+ {availableModels.map((model) => (
1623
+ <option key={model} value={model}>{model.split('/').pop()}</option>
1624
+ ))}
1625
+ {!availableModels.includes(activeModel) && activeModel && (
1626
+ <option value={activeModel}>{activeModel.split('/').pop()}</option>
1627
+ )}
1628
+ </select>
1629
+ )}
1630
+ </div>
1631
+ <button
1632
+ id="mic-btn"
1633
+ className={`${isRecording ? 'is-recording' : ''} ${voiceMode ? 'voice-mode-active' : ''}`}
1634
+ type="button"
1635
+ onClick={toggleRecording}
1636
+ title={voiceMode ? 'ปิดโหมดสนทนาเสียง' : 'เปิดโหมดสนทนาเสียง'}
1637
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
1638
+ >
1639
+ {isRecording ? (
1640
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1641
+ <rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
1642
+ </svg>
1643
+ ) : (
1644
+ <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">
1645
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
1646
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
1647
+ <line x1="12" y1="19" x2="12" y2="23"></line>
1648
+ <line x1="8" y1="23" x2="16" y2="23"></line>
1649
+ </svg>
1650
+ )}
1651
+ </button>
1652
+ <button id="send-btn" type="submit" disabled={sending || !canSubmit} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
1653
+ <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">
1654
+ <line x1="22" y1="2" x2="11" y2="13"></line>
1655
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
1656
+ </svg>
1657
+ </button>
1658
+ </form>
1659
+ </div>
1660
+ </section>
1661
+ )
1662
+ }