@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,1598 @@
1
+ import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, type RefObject } 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
+ workspacePath: string
280
+ chatEnd: RefObject<HTMLDivElement | null>
281
+ welcomeInteraction: any
282
+ onSubmit: (event: FormEvent<HTMLFormElement>) => void
283
+ onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
284
+ onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
285
+ onPasteImage: (clipboardData: DataTransfer) => boolean
286
+ onReadClipboardImage: () => Promise<boolean>
287
+ onSetMessage: (message: string) => void
288
+ onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
289
+ onRemoveImage: (idx: number) => void
290
+ onRemoveDocument: () => void
291
+ onStartWebSearch: () => void
292
+ onCaptureScreen: () => void
293
+ onSetSmartContext: (enabled: boolean) => void
294
+ onSetAgentMode: (enabled: boolean) => void
295
+ onSetProvider: (provider: string) => void
296
+ onSelectWorkspace: () => void
297
+ onApproval: (approved: boolean, autoApproveSession?: boolean) => void
298
+ settingsConfig: any
299
+ onSetModel: (model: string) => void
300
+ }
301
+
302
+ function renderFormattedMessage(text: string) {
303
+ const displayText = readableAssistantText(text)
304
+ if (!displayText) return null
305
+
306
+ const lines = displayText.split('\n')
307
+ return lines.map((line, lineIndex) => {
308
+ const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
309
+
310
+ const formatInline = (str: string) => {
311
+ const parts = str.split(/\*\*([\s\S]*?)\*\*/g)
312
+ return parts.map((part, partIndex) => {
313
+ if (partIndex % 2 === 1) {
314
+ return (
315
+ <strong key={partIndex} className="chat-bold-highlight">
316
+ {part}
317
+ </strong>
318
+ )
319
+ }
320
+ return part
321
+ })
322
+ }
323
+
324
+ if (headerMatch) {
325
+ const level = headerMatch[1].length
326
+ const content = headerMatch[2]
327
+
328
+ const style = {
329
+ fontWeight: 'bold',
330
+ display: 'block',
331
+ marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
332
+ marginBottom: '6px',
333
+ fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
334
+ color: 'var(--text-main)'
335
+ }
336
+
337
+ return (
338
+ <span key={lineIndex} style={style}>
339
+ {formatInline(content)}
340
+ </span>
341
+ )
342
+ }
343
+
344
+ return (
345
+ <Fragment key={lineIndex}>
346
+ {formatInline(line)}
347
+ {lineIndex < lines.length - 1 && '\n'}
348
+ </Fragment>
349
+ )
350
+ })
351
+ }
352
+
353
+ function readableAssistantText(text: string) {
354
+ if (typeof text !== 'string') return ''
355
+ const trimmed = text.trim()
356
+ if (!trimmed.startsWith('{')) return text
357
+ try {
358
+ const value = JSON.parse(trimmed)
359
+ if (value?.action === 'finish' && typeof value?.input?.summary === 'string' && value.input.summary.trim()) {
360
+ return value.input.summary
361
+ }
362
+ if (typeof value?.finish?.summary === 'string' && value.finish.summary.trim()) {
363
+ return value.finish.summary
364
+ }
365
+ } catch {
366
+ return text
367
+ }
368
+ return text
369
+ }
370
+
371
+ function renderSpeakerIcon(isSpeaking: boolean) {
372
+ if (isSpeaking) {
373
+ return (
374
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
375
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
376
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
377
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
378
+ </svg>
379
+ )
380
+ }
381
+ return (
382
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
383
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
384
+ </svg>
385
+ )
386
+ }
387
+
388
+ function cleanSpeechText(text: string) {
389
+ return readableAssistantText(text)
390
+ .replace(/\*\*([\s\S]*?)\*\*/g, '$1')
391
+ .replace(/[*_`#]/g, '')
392
+ .trim()
393
+ }
394
+
395
+ function numericSetting(value: unknown, fallback: number) {
396
+ const numeric = Number(value)
397
+ return Number.isFinite(numeric) ? numeric : fallback
398
+ }
399
+
400
+ interface DiffHunk {
401
+ oldText: string
402
+ newText: string
403
+ }
404
+
405
+ interface FileChange {
406
+ path: string
407
+ created: boolean
408
+ additions: number
409
+ deletions: number
410
+ hunks: DiffHunk[]
411
+ }
412
+
413
+ function parseFileChangesFromProgress(progress: AgentProgress[]): FileChange[] {
414
+ const changes = new Map<string, FileChange>()
415
+ let activeEdit: { action: string; path: string; created: boolean; additions: number; deletions: number; hunks: DiffHunk[] } | null = null
416
+
417
+ for (const event of progress || []) {
418
+ if (event.type === 'ToolStart') {
419
+ if (event.data.action === 'apply_patch') {
420
+ const patch = (event.data.input as any)?.patch
421
+ if (patch && typeof patch.path === 'string') {
422
+ let additions = 0
423
+ let deletions = 0
424
+ const hunksList: DiffHunk[] = []
425
+ const hunks = patch.hunks
426
+ if (Array.isArray(hunks)) {
427
+ for (const hunk of hunks) {
428
+ const oldText = hunk?.oldText || ''
429
+ const newText = hunk?.newText || ''
430
+ const oldLines = oldText ? oldText.split('\n').length : 0
431
+ const newLines = newText ? newText.split('\n').length : 0
432
+ deletions += oldLines
433
+ additions += newLines
434
+ hunksList.push({ oldText, newText })
435
+ }
436
+ }
437
+ activeEdit = {
438
+ action: 'apply_patch',
439
+ path: patch.path,
440
+ created: false,
441
+ additions,
442
+ deletions,
443
+ hunks: hunksList
444
+ }
445
+ }
446
+ } else if (event.data.action === 'write_file') {
447
+ const path = (event.data.input as any)?.path
448
+ const fileContent = (event.data.input as any)?.file_content || ''
449
+ if (typeof path === 'string') {
450
+ const additions = fileContent ? fileContent.split('\n').length : 0
451
+ activeEdit = {
452
+ action: 'write_file',
453
+ path,
454
+ created: true,
455
+ additions,
456
+ deletions: 0,
457
+ hunks: [{ oldText: '', newText: fileContent }]
458
+ }
459
+ }
460
+ } else {
461
+ activeEdit = null
462
+ }
463
+ } else if (event.type === 'ToolEnd') {
464
+ if (activeEdit && (event.data.action === 'apply_patch' || event.data.action === 'write_file')) {
465
+ const isError = typeof event.data.result === 'string' && event.data.result.startsWith('Error:')
466
+ if (!isError) {
467
+ try {
468
+ const applied = JSON.parse(event.data.result)
469
+ const appliedPaths = Array.isArray(applied) ? applied.map(item => item?.path).filter(Boolean) : [activeEdit.path]
470
+
471
+ for (const path of appliedPaths) {
472
+ const existing = changes.get(path)
473
+ if (existing) {
474
+ existing.additions += activeEdit.additions
475
+ existing.deletions += activeEdit.deletions
476
+ existing.hunks.push(...activeEdit.hunks)
477
+ } else {
478
+ changes.set(path, {
479
+ path,
480
+ created: activeEdit.created,
481
+ additions: activeEdit.additions,
482
+ deletions: activeEdit.deletions,
483
+ hunks: [...activeEdit.hunks]
484
+ })
485
+ }
486
+ }
487
+ } catch (e) {
488
+ const path = activeEdit.path
489
+ const existing = changes.get(path)
490
+ if (existing) {
491
+ existing.additions += activeEdit.additions
492
+ existing.deletions += activeEdit.deletions
493
+ existing.hunks.push(...activeEdit.hunks)
494
+ } else {
495
+ changes.set(path, {
496
+ path,
497
+ created: activeEdit.created,
498
+ additions: activeEdit.additions,
499
+ deletions: activeEdit.deletions,
500
+ hunks: [...activeEdit.hunks]
501
+ })
502
+ }
503
+ }
504
+ }
505
+ }
506
+ activeEdit = null
507
+ }
508
+ }
509
+
510
+ return Array.from(changes.values())
511
+ }
512
+
513
+ export default function ChatPanel({
514
+ interactions,
515
+ sending,
516
+ sendingMessage,
517
+ sendingImageCount,
518
+ streamedReply,
519
+ streamedResponse,
520
+ agentProgress,
521
+ agentActivitySnapshots,
522
+ message,
523
+ imageAttachments,
524
+ documentName,
525
+ pendingApproval,
526
+ smartContext,
527
+ agentMode,
528
+ status,
529
+ workspacePath,
530
+ chatEnd,
531
+ welcomeInteraction,
532
+ onSubmit,
533
+ onSelectImage,
534
+ onSelectDocument,
535
+ onPasteImage,
536
+ onReadClipboardImage,
537
+ onSetMessage,
538
+ onSendVoiceMessage,
539
+ onRemoveImage,
540
+ onRemoveDocument,
541
+ onStartWebSearch,
542
+ onCaptureScreen,
543
+ onSetSmartContext,
544
+ onSetAgentMode,
545
+ onSetProvider,
546
+ onSelectWorkspace,
547
+ onApproval,
548
+ settingsConfig,
549
+ onSetModel,
550
+ }: ChatPanelProps) {
551
+ const agentActivities = activitiesFrom(agentProgress)
552
+ const activeFallbackNotice = fallbackNotice(streamedResponse)
553
+ const [openActivityIds, setOpenActivityIds] = useState<Record<string, boolean>>({})
554
+ const [openReviewIds, setOpenReviewIds] = useState<Record<string, boolean>>({})
555
+ const [openFileDiffs, setOpenFileDiffs] = useState<Record<string, boolean>>({})
556
+ const [toolMenuOpen, setToolMenuOpen] = useState(false)
557
+ const [isRecording, setIsRecording] = useState(false)
558
+ const [voiceMode, setVoiceMode] = useState(false)
559
+ const [voiceTranscript, setVoiceTranscript] = useState('')
560
+ const [voiceAwaitingResponse, setVoiceAwaitingResponse] = useState(false)
561
+ const [speakingText, setSpeakingText] = useState<string | null>(null)
562
+ const toolMenuRef = useRef<HTMLDivElement | null>(null)
563
+ const recognitionRef = useRef<SpeechRecognition | null>(null)
564
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null)
565
+ const mediaStreamRef = useRef<MediaStream | null>(null)
566
+ const audioContextRef = useRef<AudioContext | null>(null)
567
+ const vadTimerRef = useRef<number | null>(null)
568
+ const audioRef = useRef<HTMLAudioElement | null>(null)
569
+ const speechRunRef = useRef(0)
570
+ const historyReadyRef = useRef(false)
571
+ const submittedDuringSessionRef = useRef(false)
572
+ const lastAutoSpokenIdRef = useRef<number | string | null>(null)
573
+ const voiceModeRef = useRef(false)
574
+ const sendingRef = useRef(false)
575
+ const voiceAwaitingResponseRef = useRef(false)
576
+ const speakingRef = useRef<string | null>(null)
577
+ const restartTimerRef = useRef<number | null>(null)
578
+ const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
579
+ const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
580
+ const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
581
+ const voiceStatusLabel = voiceStatus === 'speaking' ? 'กำลังตอบ' : voiceStatus === 'thinking' ? 'กำลังคิด' : voiceStatus === 'listening' ? 'กำลังฟัง' : 'พร้อมฟัง'
582
+
583
+ const getAvailableModels = (provider: string) => {
584
+ switch (provider) {
585
+ case 'gemini':
586
+ return GEMINI_MODELS
587
+ case 'openai':
588
+ return OPENAI_MODELS
589
+ case 'openrouter':
590
+ return OPENROUTER_MODELS
591
+ case 'deepseek':
592
+ return DEEPSEEK_MODELS
593
+ case 'anthropic':
594
+ return ANTHROPIC_MODELS
595
+ case 'huggingface':
596
+ return HF_MODELS
597
+ case 'local_openai':
598
+ return LOCAL_MODELS
599
+ case 'ollama':
600
+ return OLLAMA_MODELS
601
+ default:
602
+ return []
603
+ }
604
+ }
605
+
606
+ const activeProvider = status?.activeProvider ?? ''
607
+ const availableModels = getAvailableModels(activeProvider)
608
+
609
+ const getActiveModel = (provider: string) => {
610
+ if (!settingsConfig) return ''
611
+ switch (provider) {
612
+ case 'gemini':
613
+ return settingsConfig.geminiModel
614
+ case 'openai':
615
+ return settingsConfig.openaiModel
616
+ case 'openrouter':
617
+ return settingsConfig.openrouterModel
618
+ case 'deepseek':
619
+ return settingsConfig.deepseekModel
620
+ case 'anthropic':
621
+ return settingsConfig.anthropicModel
622
+ case 'huggingface':
623
+ return settingsConfig.hfModel
624
+ case 'local_openai':
625
+ return settingsConfig.localModelName
626
+ case 'ollama':
627
+ return settingsConfig.ollamaModel
628
+ default:
629
+ return ''
630
+ }
631
+ }
632
+ const activeModel = getActiveModel(activeProvider)
633
+ const workspaceName = workspacePath
634
+ ? workspacePath.split(/[\\/]/).filter(Boolean).pop() || workspacePath
635
+ : 'Select Project'
636
+ const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
637
+ if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
638
+ event.preventDefault()
639
+ event.currentTarget.form?.requestSubmit()
640
+ }
641
+ const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
642
+ submitOnEnter(event)
643
+ }
644
+ const clearRestartTimer = () => {
645
+ if (restartTimerRef.current === null) return
646
+ window.clearTimeout(restartTimerRef.current)
647
+ restartTimerRef.current = null
648
+ }
649
+ const clearVadTimer = () => {
650
+ if (vadTimerRef.current === null) return
651
+ window.clearInterval(vadTimerRef.current)
652
+ vadTimerRef.current = null
653
+ }
654
+ const stopRecognition = () => {
655
+ clearRestartTimer()
656
+ recognitionRef.current?.stop()
657
+ recognitionRef.current = null
658
+ clearVadTimer()
659
+ if (mediaRecorderRef.current?.state === 'recording') {
660
+ mediaRecorderRef.current.stop()
661
+ }
662
+ mediaRecorderRef.current = null
663
+ mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
664
+ mediaStreamRef.current = null
665
+ audioContextRef.current?.close().catch(() => {})
666
+ audioContextRef.current = null
667
+ setIsRecording(false)
668
+ }
669
+ const scheduleVoiceListen = (delayMs = 350) => {
670
+ clearRestartTimer()
671
+ if (!voiceModeRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
672
+ restartTimerRef.current = window.setTimeout(() => {
673
+ restartTimerRef.current = null
674
+ startRecognition(true)
675
+ }, delayMs)
676
+ }
677
+ const cancelSpeech = () => {
678
+ speechRunRef.current += 1
679
+ audioRef.current?.pause()
680
+ audioRef.current = null
681
+ speakingRef.current = null
682
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
683
+ window.speechSynthesis.cancel()
684
+ }
685
+ setSpeakingText(null)
686
+ }
687
+ const speakNative = (text: string, displayText: string) => {
688
+ if (typeof window === 'undefined' || !window.speechSynthesis) {
689
+ setSpeakingText(null)
690
+ return
691
+ }
692
+ const hasThai = /[\u0e00-\u0e7f]/.test(text)
693
+ const utterance = new SpeechSynthesisUtterance(text)
694
+ utterance.lang = hasThai ? 'th-TH' : 'en-US'
695
+ utterance.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
696
+ utterance.rate = Math.max(0.1, Math.min(10, numericSetting(settingsConfig?.ttsSpeed, 1)))
697
+ utterance.pitch = Math.max(0, Math.min(2, numericSetting(settingsConfig?.ttsPitch, 1)))
698
+ const voice = window.speechSynthesis.getVoices().find((item) => item.lang.startsWith(hasThai ? 'th' : 'en'))
699
+ if (voice) utterance.voice = voice
700
+ const finishSpeech = () => {
701
+ setSpeakingText((current) => (current === displayText ? null : current))
702
+ speakingRef.current = null
703
+ scheduleVoiceListen(900)
704
+ }
705
+ utterance.onend = finishSpeech
706
+ utterance.onerror = finishSpeech
707
+ speakingRef.current = displayText
708
+ setSpeakingText(displayText)
709
+ window.speechSynthesis.speak(utterance)
710
+ }
711
+ const playGoogleTts = async (text: string, displayText: string, runId: number) => {
712
+ const chunks = await getTtsUrls(text)
713
+ for (const chunk of chunks) {
714
+ if (speechRunRef.current !== runId) return
715
+ await new Promise<void>((resolve, reject) => {
716
+ const audio = new Audio(chunk.url)
717
+ audio.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
718
+ audio.playbackRate = Math.max(0.25, Math.min(4, numericSetting(settingsConfig?.ttsSpeed, 1)))
719
+ audioRef.current = audio
720
+ audio.onended = () => resolve()
721
+ audio.onpause = () => resolve()
722
+ audio.onerror = () => reject(new Error('Google TTS playback failed'))
723
+ audio.play().catch(reject)
724
+ })
725
+ }
726
+ if (speechRunRef.current === runId) {
727
+ setSpeakingText((current) => (current === displayText ? null : current))
728
+ speakingRef.current = null
729
+ scheduleVoiceListen(900)
730
+ }
731
+ }
732
+ const speak = (text: string) => {
733
+ const speechText = cleanSpeechText(text)
734
+ if (!speechText) return
735
+ if (speakingText === text) {
736
+ cancelSpeech()
737
+ return
738
+ }
739
+
740
+ cancelSpeech()
741
+ const runId = speechRunRef.current
742
+ speakingRef.current = text
743
+ setSpeakingText(text)
744
+ if (settingsConfig?.ttsProvider === 'google') {
745
+ playGoogleTts(speechText, text, runId).catch((error) => {
746
+ console.warn('Google TTS failed, falling back to native speech synthesis:', error)
747
+ if (speechRunRef.current === runId) speakNative(speechText, text)
748
+ })
749
+ return
750
+ }
751
+ speakNative(speechText, text)
752
+ }
753
+ const blobToDataUri = (blob: Blob) => new Promise<string>((resolve, reject) => {
754
+ const reader = new FileReader()
755
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read audio recording'))
756
+ reader.onload = () => resolve(String(reader.result))
757
+ reader.readAsDataURL(blob)
758
+ })
759
+ const preferredAudioMimeType = () => {
760
+ const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']
761
+ return candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? ''
762
+ }
763
+ const startAudioRecording = async (autoSend: boolean) => {
764
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
765
+ alert('ขออภัย ระบบนี้ไม่สามารถเข้าถึงไมค์หรืออัดเสียงใน WebView นี้ได้')
766
+ voiceModeRef.current = false
767
+ setVoiceMode(false)
768
+ return
769
+ }
770
+
771
+ try {
772
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
773
+ const mimeType = preferredAudioMimeType()
774
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
775
+ const chunks: Blob[] = []
776
+ let heardVoice = false
777
+ let quietSince = 0
778
+ const startedAt = Date.now()
779
+ const audioContext = new AudioContext()
780
+ const source = audioContext.createMediaStreamSource(stream)
781
+ const analyser = audioContext.createAnalyser()
782
+ const samples = new Uint8Array(analyser.fftSize)
783
+ source.connect(analyser)
784
+
785
+ mediaStreamRef.current = stream
786
+ mediaRecorderRef.current = recorder
787
+ audioContextRef.current = audioContext
788
+ setVoiceTranscript('กำลังฟังเสียงจากไมค์')
789
+
790
+ recorder.ondataavailable = (event) => {
791
+ if (event.data.size > 0) chunks.push(event.data)
792
+ }
793
+ recorder.onstop = async () => {
794
+ clearVadTimer()
795
+ mediaRecorderRef.current = null
796
+ mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
797
+ mediaStreamRef.current = null
798
+ audioContextRef.current?.close().catch(() => {})
799
+ audioContextRef.current = null
800
+ setIsRecording(false)
801
+ if (!autoSend || !voiceModeRef.current || chunks.length === 0) return
802
+ if (!heardVoice) {
803
+ setVoiceTranscript('ยังไม่ได้ยินเสียงพูด')
804
+ scheduleVoiceListen(700)
805
+ return
806
+ }
807
+ const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
808
+ const audioDataUri = await blobToDataUri(blob)
809
+ setVoiceTranscript('ส่งเสียงให้ AI แล้ว')
810
+ voiceAwaitingResponseRef.current = true
811
+ setVoiceAwaitingResponse(true)
812
+ onSendVoiceMessage('', audioDataUri)
813
+ .catch((error) => console.error('Voice audio message failed', error))
814
+ .finally(() => {
815
+ voiceAwaitingResponseRef.current = false
816
+ setVoiceAwaitingResponse(false)
817
+ scheduleVoiceListen()
818
+ })
819
+ }
820
+
821
+ recorder.start()
822
+ setIsRecording(true)
823
+ vadTimerRef.current = window.setInterval(() => {
824
+ analyser.getByteTimeDomainData(samples)
825
+ let peak = 0
826
+ for (const sample of samples) {
827
+ peak = Math.max(peak, Math.abs(sample - 128))
828
+ }
829
+ const now = Date.now()
830
+ if (peak > 12) {
831
+ heardVoice = true
832
+ quietSince = 0
833
+ setVoiceTranscript('กำลังฟัง...')
834
+ } else if (heardVoice) {
835
+ quietSince = quietSince || now
836
+ }
837
+ const silenceElapsed = quietSince ? now - quietSince : 0
838
+ const totalElapsed = now - startedAt
839
+ if ((heardVoice && silenceElapsed > 1300) || totalElapsed > 12000) {
840
+ recorder.stop()
841
+ }
842
+ }, 120)
843
+ } catch (error) {
844
+ console.error('Failed to record microphone audio', error)
845
+ setIsRecording(false)
846
+ voiceModeRef.current = false
847
+ setVoiceMode(false)
848
+ alert('เปิดไมค์ไม่สำเร็จ กรุณาตรวจสิทธิ์ microphone ของแอป')
849
+ }
850
+ }
851
+ const startRecognition = (autoSend: boolean) => {
852
+ if (recognitionRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
853
+
854
+ const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition
855
+ if (!SpeechRecognitionApi) {
856
+ startAudioRecording(autoSend)
857
+ return
858
+ }
859
+
860
+ let sentTranscript = false
861
+
862
+ try {
863
+ const recognition = new SpeechRecognitionApi()
864
+ recognition.continuous = false
865
+ recognition.interimResults = true
866
+ recognition.lang = settingsConfig?.language === 'en' ? 'en-US' : 'th-TH'
867
+ recognition.onstart = () => setIsRecording(true)
868
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
869
+ let interimText = ''
870
+ let finalText = ''
871
+ for (let index = event.resultIndex; index < event.results.length; index += 1) {
872
+ const transcript = event.results[index]?.[0]?.transcript ?? ''
873
+ if (event.results[index]?.isFinal) finalText += transcript
874
+ else interimText += transcript
875
+ }
876
+ const displayText = (finalText || interimText).trim()
877
+ if (displayText) setVoiceTranscript(displayText)
878
+ const resultText = finalText.trim()
879
+ if (!resultText) return
880
+ sentTranscript = true
881
+ recognition.stop()
882
+ setVoiceTranscript(resultText)
883
+ if (autoSend) {
884
+ voiceAwaitingResponseRef.current = true
885
+ setVoiceAwaitingResponse(true)
886
+ onSendVoiceMessage(resultText)
887
+ .catch((error) => console.error('Voice message failed', error))
888
+ .finally(() => {
889
+ voiceAwaitingResponseRef.current = false
890
+ setVoiceAwaitingResponse(false)
891
+ scheduleVoiceListen()
892
+ })
893
+ } else {
894
+ onSetMessage(message.trim() ? `${message.trimEnd()} ${resultText}` : resultText)
895
+ }
896
+ }
897
+ recognition.onerror = (event: Event) => {
898
+ console.error('Speech recognition error', event)
899
+ setIsRecording(false)
900
+ }
901
+ recognition.onend = () => {
902
+ recognitionRef.current = null
903
+ setIsRecording(false)
904
+ if (autoSend && voiceModeRef.current && !sentTranscript) scheduleVoiceListen()
905
+ }
906
+ recognitionRef.current = recognition
907
+ recognition.start()
908
+ } catch (error) {
909
+ console.error('Failed to start speech recognition', error)
910
+ recognitionRef.current = null
911
+ setIsRecording(false)
912
+ }
913
+ }
914
+ const toggleRecording = () => {
915
+ if (voiceMode) {
916
+ voiceModeRef.current = false
917
+ voiceAwaitingResponseRef.current = false
918
+ setVoiceMode(false)
919
+ setVoiceAwaitingResponse(false)
920
+ setVoiceTranscript('')
921
+ stopRecognition()
922
+ cancelSpeech()
923
+ return
924
+ }
925
+
926
+ voiceModeRef.current = true
927
+ setVoiceMode(true)
928
+ setVoiceAwaitingResponse(false)
929
+ setVoiceTranscript('')
930
+ startRecognition(true)
931
+ }
932
+ const resizeInput = (element: HTMLTextAreaElement) => {
933
+ element.style.height = 'auto'
934
+ element.style.height = `${Math.min(element.scrollHeight, 120)}px`
935
+ }
936
+ useEffect(() => {
937
+ return () => {
938
+ clearRestartTimer()
939
+ cancelSpeech()
940
+ stopRecognition()
941
+ }
942
+ }, [])
943
+ useEffect(() => {
944
+ voiceModeRef.current = voiceMode
945
+ if (!voiceMode) {
946
+ clearRestartTimer()
947
+ setVoiceTranscript('')
948
+ }
949
+ }, [voiceMode])
950
+ useEffect(() => {
951
+ sendingRef.current = sending
952
+ }, [sending])
953
+ useEffect(() => {
954
+ voiceAwaitingResponseRef.current = voiceAwaitingResponse
955
+ }, [voiceAwaitingResponse])
956
+ useEffect(() => {
957
+ speakingRef.current = speakingText
958
+ }, [speakingText])
959
+ useEffect(() => {
960
+ if (!voiceMode || sending || voiceAwaitingResponse || speakingText || isRecording) return
961
+ scheduleVoiceListen()
962
+ }, [voiceMode, sending, voiceAwaitingResponse, speakingText, isRecording])
963
+ useEffect(() => {
964
+ if (sending) submittedDuringSessionRef.current = true
965
+ }, [sending])
966
+ useEffect(() => {
967
+ if (interactions.length === 0) return
968
+ const latest = interactions[interactions.length - 1]
969
+ if (!historyReadyRef.current) {
970
+ historyReadyRef.current = true
971
+ if (!submittedDuringSessionRef.current) {
972
+ lastAutoSpokenIdRef.current = latest?.id ?? null
973
+ return
974
+ }
975
+ }
976
+ if (sending) return
977
+ if (!settingsConfig?.enableVoiceReply && !voiceMode) {
978
+ lastAutoSpokenIdRef.current = latest?.id ?? null
979
+ return
980
+ }
981
+ if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
982
+ lastAutoSpokenIdRef.current = latest.id
983
+ speak(latest.aiText)
984
+ }, [interactions, sending, settingsConfig?.enableVoiceReply])
985
+ useEffect(() => {
986
+ if (!toolMenuOpen) return
987
+ const closeMenu = (event: MouseEvent) => {
988
+ if (toolMenuRef.current?.contains(event.target as Node)) return
989
+ setToolMenuOpen(false)
990
+ }
991
+ window.addEventListener('mousedown', closeMenu)
992
+ return () => window.removeEventListener('mousedown', closeMenu)
993
+ }, [toolMenuOpen])
994
+ useEffect(() => {
995
+ const handleWindowPaste = (event: globalThis.ClipboardEvent) => {
996
+ if (!event.clipboardData) return
997
+ if (onPasteImage(event.clipboardData)) {
998
+ event.preventDefault()
999
+ event.stopPropagation()
1000
+ } else {
1001
+ window.setTimeout(() => void onReadClipboardImage(), 0)
1002
+ }
1003
+ }
1004
+ window.addEventListener('paste', handleWindowPaste, true)
1005
+ return () => window.removeEventListener('paste', handleWindowPaste, true)
1006
+ }, [onPasteImage, onReadClipboardImage])
1007
+ useEffect(() => {
1008
+ const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1009
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'v') return
1010
+ window.setTimeout(() => void onReadClipboardImage(), 0)
1011
+ }
1012
+ window.addEventListener('keydown', handleWindowKeyDown, true)
1013
+ return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
1014
+ }, [onReadClipboardImage])
1015
+ useEffect(() => {
1016
+ if (message) return
1017
+ const input = document.getElementById('chat-input') as HTMLTextAreaElement | null
1018
+ if (input) input.style.height = ''
1019
+ }, [message])
1020
+
1021
+ const openImagePicker = () => {
1022
+ setToolMenuOpen(false)
1023
+ document.getElementById('vision-file-input')?.click()
1024
+ }
1025
+ const openDocumentPicker = () => {
1026
+ setToolMenuOpen(false)
1027
+ document.getElementById('document-file-input')?.click()
1028
+ }
1029
+ const startWebSearch = () => {
1030
+ setToolMenuOpen(false)
1031
+ onStartWebSearch()
1032
+ }
1033
+ const appendWorkspaceReference = (reference: string) => {
1034
+ const trimmed = reference.trim()
1035
+ if (!trimmed) return
1036
+ onSetMessage(message.trim() ? `${message.trimEnd()} ${trimmed}` : trimmed)
1037
+ }
1038
+ const handleWorkspaceDrop = (event: DragEvent<HTMLElement>) => {
1039
+ const reference =
1040
+ event.dataTransfer.getData('application/x-mint-workspace-path') ||
1041
+ event.dataTransfer.getData('text/plain')
1042
+ if (!reference.trim().startsWith('@')) return
1043
+ event.preventDefault()
1044
+ appendWorkspaceReference(reference)
1045
+ }
1046
+ const isEmptyChat = interactions.length === 0 && !sending && !pendingApproval
1047
+ const renderCompletedActivity = (interaction: any) => {
1048
+ const interactionId = String(interaction.id)
1049
+ const activityView = activitiesFrom(agentActivitySnapshots[interactionId] ?? [])
1050
+ if (activityView.items.length === 0) return null
1051
+ const isOpen = Boolean(openActivityIds[interactionId])
1052
+ return (
1053
+ <div className="agent-activity-history">
1054
+ <button
1055
+ type="button"
1056
+ className="agent-activity-toggle"
1057
+ aria-expanded={isOpen}
1058
+ onClick={() => setOpenActivityIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
1059
+ >
1060
+ <span>{activityView.summary}</span>
1061
+ <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1062
+ </button>
1063
+ {isOpen && (
1064
+ <div className="agent-activity-card agent-activity-card-history">
1065
+ {renderAgentActivityTable(activityView)}
1066
+ </div>
1067
+ )}
1068
+ </div>
1069
+ )
1070
+ }
1071
+
1072
+ const renderFileChanges = (interaction: any) => {
1073
+ const interactionId = String(interaction.id)
1074
+ const progress = agentActivitySnapshots[interactionId] ?? []
1075
+ const changes = parseFileChangesFromProgress(progress)
1076
+ if (changes.length === 0) return null
1077
+
1078
+ const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0)
1079
+ const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0)
1080
+ const isOpen = Boolean(openReviewIds[interactionId])
1081
+
1082
+ return (
1083
+ <div className="file-changes-summary-container" style={{ marginBottom: '8px' }}>
1084
+ <button
1085
+ type="button"
1086
+ className="agent-activity-toggle"
1087
+ aria-expanded={isOpen}
1088
+ onClick={() => setOpenReviewIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
1089
+ style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontWeight: 500 }}
1090
+ >
1091
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '2px' }}>
1092
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
1093
+ <polyline points="22 4 12 14.01 9 11.01" />
1094
+ </svg>
1095
+ <span>
1096
+ {changes.length} {changes.length === 1 ? 'file' : 'files'} changed
1097
+ {totalAdditions > 0 && <span style={{ color: '#10b981', marginLeft: '6px' }}>+{totalAdditions}</span>}
1098
+ {totalDeletions > 0 && <span style={{ color: '#ef4444', marginLeft: '4px' }}>-{totalDeletions}</span>}
1099
+ </span>
1100
+ <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1101
+ </button>
1102
+
1103
+ {isOpen && (
1104
+ <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' }}>
1105
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
1106
+ {changes.map((change) => {
1107
+ const fileKey = `${interactionId}-${change.path}`
1108
+ const isDiffOpen = Boolean(openFileDiffs[fileKey])
1109
+ const fileName = change.path.split('/').pop() || change.path
1110
+ const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : ''
1111
+
1112
+ return (
1113
+ <div key={change.path} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)', paddingBottom: '4px' }}>
1114
+ <div
1115
+ onClick={() => setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))}
1116
+ style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }}
1117
+ >
1118
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1119
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1120
+ <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" />
1121
+ <polyline points="14 2 14 8 20 8" />
1122
+ </svg>
1123
+ <span style={{ fontSize: '0.82rem', fontWeight: 600, color: change.created ? '#10b981' : '#cbd5e1' }}>
1124
+ {fileName}
1125
+ {dirPath && <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 400, marginLeft: '6px' }}>{dirPath}</span>}
1126
+ {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>}
1127
+ </span>
1128
+ </div>
1129
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.76rem' }}>
1130
+ {change.additions > 0 && <span style={{ color: '#10b981' }}>+{change.additions}</span>}
1131
+ {change.deletions > 0 && <span style={{ color: '#ef4444' }}>-{change.deletions}</span>}
1132
+ <span style={{ color: '#64748b', transform: isDiffOpen ? 'rotate(90deg)' : 'none', display: 'inline-block', transition: 'transform 0.15s' }}>&gt;</span>
1133
+ </div>
1134
+ </div>
1135
+
1136
+ {isDiffOpen && (
1137
+ <div style={{ marginTop: '6px', background: '#0b0f19', borderRadius: '6px', padding: '8px', border: '1px solid rgba(255, 255, 255, 0.08)', overflowX: 'auto', maxHeight: '300px' }}>
1138
+ {change.hunks.map((hunk, hIdx) => (
1139
+ <div key={hIdx} style={{ fontSize: '0.74rem', fontFamily: 'monospace', lineHeight: '1.4', marginBottom: hIdx < change.hunks.length - 1 ? '10px' : 0 }}>
1140
+ {hunk.oldText && (
1141
+ <div style={{ background: 'rgba(239, 68, 68, 0.12)', borderLeft: '3px solid #ef4444', padding: '4px 6px', color: '#fca5a5', whiteSpace: 'pre-wrap' }}>
1142
+ {hunk.oldText.split('\n').map((line, lIdx) => (
1143
+ <div key={lIdx}>- {line}</div>
1144
+ ))}
1145
+ </div>
1146
+ )}
1147
+ {hunk.newText && (
1148
+ <div style={{ background: 'rgba(16, 185, 129, 0.12)', borderLeft: '3px solid #10b981', padding: '4px 6px', color: '#a7f3d0', whiteSpace: 'pre-wrap' }}>
1149
+ {hunk.newText.split('\n').map((line, lIdx) => (
1150
+ <div key={lIdx}>+ {line}</div>
1151
+ ))}
1152
+ </div>
1153
+ )}
1154
+ </div>
1155
+ ))}
1156
+ </div>
1157
+ )}
1158
+ </div>
1159
+ )
1160
+ })}
1161
+ </div>
1162
+ </div>
1163
+ )}
1164
+ </div>
1165
+ )
1166
+ }
1167
+
1168
+ const renderActiveFileChanges = () => {
1169
+ const changes = parseFileChangesFromProgress(agentProgress)
1170
+ if (changes.length === 0) return null
1171
+
1172
+ const totalAdditions = changes.reduce((sum, c) => sum + c.additions, 0)
1173
+ const totalDeletions = changes.reduce((sum, c) => sum + c.deletions, 0)
1174
+ const isOpen = Boolean(openReviewIds['active-run'])
1175
+
1176
+ return (
1177
+ <div className="message ai-message agent-activity-message" style={{ marginTop: '4px', marginBottom: '8px' }}>
1178
+ <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)' }}>
1179
+ <button
1180
+ type="button"
1181
+ className="agent-activity-toggle"
1182
+ aria-expanded={isOpen}
1183
+ onClick={() => setOpenReviewIds((current) => ({ ...current, 'active-run': !current['active-run'] }))}
1184
+ style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontWeight: 500, border: 0, background: 'transparent', padding: 0 }}
1185
+ >
1186
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '2px' }}>
1187
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
1188
+ <polyline points="22 4 12 14.01 9 11.01" />
1189
+ </svg>
1190
+ <span>
1191
+ {changes.length} {changes.length === 1 ? 'file' : 'files'} changed in this run
1192
+ {totalAdditions > 0 && <span style={{ color: '#10b981', marginLeft: '6px' }}>+{totalAdditions}</span>}
1193
+ {totalDeletions > 0 && <span style={{ color: '#ef4444', marginLeft: '4px' }}>-{totalDeletions}</span>}
1194
+ </span>
1195
+ <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1196
+ </button>
1197
+
1198
+ {isOpen && (
1199
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '8px' }}>
1200
+ {changes.map((change) => {
1201
+ const fileKey = `active-${change.path}`
1202
+ const isDiffOpen = Boolean(openFileDiffs[fileKey])
1203
+ const fileName = change.path.split('/').pop() || change.path
1204
+ const dirPath = change.path.includes('/') ? change.path.substring(0, change.path.lastIndexOf('/')) : ''
1205
+
1206
+ return (
1207
+ <div key={change.path} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)', paddingBottom: '4px' }}>
1208
+ <div
1209
+ onClick={() => setOpenFileDiffs((current) => ({ ...current, [fileKey]: !current[fileKey] }))}
1210
+ style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px', borderRadius: '4px', background: 'rgba(255, 255, 255, 0.02)' }}
1211
+ >
1212
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1213
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1214
+ <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" />
1215
+ <polyline points="14 2 14 8 20 8" />
1216
+ </svg>
1217
+ <span style={{ fontSize: '0.82rem', fontWeight: 600, color: change.created ? '#10b981' : '#cbd5e1' }}>
1218
+ {fileName}
1219
+ {dirPath && <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 400, marginLeft: '6px' }}>{dirPath}</span>}
1220
+ {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>}
1221
+ </span>
1222
+ </div>
1223
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.76rem' }}>
1224
+ {change.additions > 0 && <span style={{ color: '#10b981' }}>+{change.additions}</span>}
1225
+ {change.deletions > 0 && <span style={{ color: '#ef4444' }}>-{change.deletions}</span>}
1226
+ <span style={{ color: '#64748b', transform: isDiffOpen ? 'rotate(90deg)' : 'none', display: 'inline-block', transition: 'transform 0.15s' }}>&gt;</span>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ {isDiffOpen && (
1231
+ <div style={{ marginTop: '6px', background: '#0b0f19', borderRadius: '6px', padding: '8px', border: '1px solid rgba(255, 255, 255, 0.08)', overflowX: 'auto', maxHeight: '300px' }}>
1232
+ {change.hunks.map((hunk, hunkIdx) => (
1233
+ <div key={hunkIdx} style={{ fontSize: '0.74rem', fontFamily: 'monospace', lineHeight: '1.4', marginBottom: hunkIdx < change.hunks.length - 1 ? '10px' : 0 }}>
1234
+ {hunk.oldText && (
1235
+ <div style={{ background: 'rgba(239, 68, 68, 0.12)', borderLeft: '3px solid #ef4444', padding: '4px 6px', color: '#fca5a5', whiteSpace: 'pre-wrap' }}>
1236
+ {hunk.oldText.split('\n').map((line, lIdx) => (
1237
+ <div key={lIdx}>- {line}</div>
1238
+ ))}
1239
+ </div>
1240
+ )}
1241
+ {hunk.newText && (
1242
+ <div style={{ background: 'rgba(16, 185, 129, 0.12)', borderLeft: '3px solid #10b981', padding: '4px 6px', color: '#a7f3d0', whiteSpace: 'pre-wrap' }}>
1243
+ {hunk.newText.split('\n').map((line, lIdx) => (
1244
+ <div key={lIdx}>+ {line}</div>
1245
+ ))}
1246
+ </div>
1247
+ )}
1248
+ </div>
1249
+ ))}
1250
+ </div>
1251
+ )}
1252
+ </div>
1253
+ )
1254
+ })}
1255
+ </div>
1256
+ )}
1257
+ </div>
1258
+ </div>
1259
+ )
1260
+ }
1261
+
1262
+ return (
1263
+ <section className={`conversation-panel ${isEmptyChat ? 'is-empty' : ''}`}>
1264
+ <div className="chat-container">
1265
+ {interactions.map((interaction) => (
1266
+ <div key={interaction.id} style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1267
+ {interaction.isSystemEvent ? (
1268
+ <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' }}>
1269
+ {interaction.userText}
1270
+ </div>
1271
+ ) : interaction.userText && (
1272
+ <div className="message user-message">
1273
+ <div className="bubble-wrapper">
1274
+ <div className="message-bubble">{renderFormattedMessage(interaction.userText)}</div>
1275
+ <div className="message-time"><span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span></div>
1276
+ </div>
1277
+ </div>
1278
+ )}
1279
+ <div className="message ai-message">
1280
+ <div className="bubble-wrapper">
1281
+ {renderCompletedActivity(interaction)}
1282
+ {renderFileChanges(interaction)}
1283
+ <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
1284
+ <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1285
+ <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
1286
+ {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
1287
+ <span>{new Date(interaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
1288
+ <button
1289
+ type="button"
1290
+ className={`tts-btn ${speakingText === interaction.aiText ? 'is-speaking' : ''}`}
1291
+ onClick={() => speak(interaction.aiText)}
1292
+ title={speakingText === interaction.aiText ? 'หยุดอ่านออกเสียง' : 'อ่านออกเสียง'}
1293
+ >
1294
+ {renderSpeakerIcon(speakingText === interaction.aiText)}
1295
+ </button>
1296
+ </div>
1297
+ </div>
1298
+ </div>
1299
+ </div>
1300
+ ))}
1301
+
1302
+ {sending && (
1303
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1304
+ <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
1305
+ {agentMode && agentActivities.items.length > 0 && (
1306
+ <div className="message ai-message agent-activity-message">
1307
+ <div className="agent-activity-card">
1308
+ <div className="agent-activity-header">
1309
+ <span>{agentActivities.summary}</span>
1310
+ <span className="agent-activity-status" data-state={pendingApproval ? 'approval' : 'active'}>
1311
+ {pendingApproval ? 'Waiting for approval' : 'Working'}
1312
+ </span>
1313
+ </div>
1314
+ {renderAgentActivityTable(agentActivities)}
1315
+ </div>
1316
+ </div>
1317
+ )}
1318
+ {renderActiveFileChanges()}
1319
+ <div className="message ai-message thinking-message">
1320
+ <div className="bubble-wrapper">
1321
+ <div className="message-bubble"><span>{streamedReply ? renderFormattedMessage(streamedReply) : 'Thinking...'}</span></div>
1322
+ {streamedResponse && (
1323
+ <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1324
+ <button className="provider-badge">{badge(streamedResponse.provider, streamedResponse.model)}</button>
1325
+ {activeFallbackNotice && <span className="provider-fallback-notice">{activeFallbackNotice}</span>}
1326
+ {streamedReply && (
1327
+ <button
1328
+ type="button"
1329
+ className={`tts-btn ${speakingText === streamedReply ? 'is-speaking' : ''}`}
1330
+ onClick={() => speak(streamedReply)}
1331
+ title={speakingText === streamedReply ? 'หยุดอ่านออกเสียง' : 'อ่านออกเสียง'}
1332
+ >
1333
+ {renderSpeakerIcon(speakingText === streamedReply)}
1334
+ </button>
1335
+ )}
1336
+ </div>
1337
+ )}
1338
+ </div>
1339
+ </div>
1340
+ </div>
1341
+ )}
1342
+
1343
+ {pendingApproval && (() => {
1344
+ const details = renderApprovalDetails(pendingApproval.approval)
1345
+ return (
1346
+ <div className="message ai-message" style={{ width: '100%' }}>
1347
+ <div className="bubble-wrapper" style={{ width: '100%' }}>
1348
+ <div className="action-card approval-card" data-tier={details.isDangerous ? 'dangerous' : undefined} style={{ width: '100%' }}>
1349
+ <div className="approval-card-content">
1350
+ <div className="approval-card-title">{details.title}</div>
1351
+ <div className="approval-card-body" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.body}</div>
1352
+ {details.reason && <div className="approval-card-reason" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.reason}</div>}
1353
+ </div>
1354
+ <div className="approval-card-actions">
1355
+ <button type="button" className="approval-btn approval-btn-approve" onClick={() => onApproval(true)}>Approve</button>
1356
+ <button type="button" className="approval-btn" style={{ backgroundColor: 'rgba(16, 185, 129, 0.22)', borderColor: 'rgba(16, 185, 129, 0.4)', color: '#a7f3d0' }} onClick={() => onApproval(true, true)}>Approve this session</button>
1357
+ <button type="button" className="approval-btn approval-btn-cancel" onClick={() => onApproval(false)}>Cancel</button>
1358
+ </div>
1359
+ </div>
1360
+ </div>
1361
+ </div>
1362
+ )
1363
+ })()}
1364
+ <div ref={chatEnd} />
1365
+ </div>
1366
+
1367
+ <div className="input-area">
1368
+ {isEmptyChat && <div className="empty-chat-prompt">Mint Agent is ready to work</div>}
1369
+ <button type="button" className="workspace-select-btn" onClick={onSelectWorkspace}>
1370
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1371
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
1372
+ <path d="M3 6h7l2 2h9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
1373
+ </svg>
1374
+ </span>
1375
+ <span>{workspaceName}</span>
1376
+ <span aria-hidden="true">⌄</span>
1377
+ </button>
1378
+ <div className="smart-context-bar">
1379
+ <div className="smart-context-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1380
+ <label className="toggle-switch">
1381
+ <input type="checkbox" checked={smartContext} onChange={(event) => onSetSmartContext(event.target.checked)} />
1382
+ <span className="slider round" />
1383
+ </label>
1384
+ <span>Smart Context (Auto-Screen)</span>
1385
+ </div>
1386
+ <div className="smart-context-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1387
+ <label className="toggle-switch">
1388
+ <input type="checkbox" checked={agentMode} onChange={(event) => onSetAgentMode(event.target.checked)} />
1389
+ <span className="slider round" />
1390
+ </label>
1391
+ <span>Agent Mode</span>
1392
+ </div>
1393
+ </div>
1394
+ {voiceMode && (
1395
+ <div className="voice-mode-bar" data-state={voiceStatus}>
1396
+ <span className="voice-mode-dot" />
1397
+ <span>{voiceStatusLabel}</span>
1398
+ {voiceTranscript && <span className="voice-mode-transcript">{voiceTranscript}</span>}
1399
+ </div>
1400
+ )}
1401
+
1402
+ <form
1403
+ id="chat-form"
1404
+ onSubmit={onSubmit}
1405
+ onDragOver={(event) => {
1406
+ if (event.dataTransfer.types.includes('application/x-mint-workspace-path')) {
1407
+ event.preventDefault()
1408
+ event.dataTransfer.dropEffect = 'copy'
1409
+ }
1410
+ }}
1411
+ onDrop={handleWorkspaceDrop}
1412
+ onPaste={(event: ClipboardEvent<HTMLElement>) => {
1413
+ if (onPasteImage(event.clipboardData)) event.preventDefault()
1414
+ }}
1415
+ >
1416
+ {(imageAttachments.length > 0 || documentName) && (
1417
+ <div className="mint-attachment">
1418
+ {imageAttachments.map((attachment, idx) => (
1419
+ <div className="mint-image-attachment" key={idx}>
1420
+ <img className="mint-image-preview" src={attachment.previewDataUri || attachment.dataUri} alt={attachment.name || 'Image attachment'} />
1421
+ <button className="mint-attachment-remove" type="button" onClick={() => onRemoveImage(idx)} aria-label="Remove image">
1422
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1423
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1424
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1425
+ </svg>
1426
+ </button>
1427
+ </div>
1428
+ ))}
1429
+ {documentName && (
1430
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
1431
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--text-soft)' }}>
1432
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1433
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1434
+ <polyline points="14 2 14 8 20 8"></polyline>
1435
+ </svg>
1436
+ </span>
1437
+ <span style={{ fontSize: '0.76rem', color: 'var(--text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '220px' }}>{documentName}</span>
1438
+ <button type="button" onClick={onRemoveDocument} style={{ background: 'transparent', border: 0, color: '#ef4444', cursor: 'pointer', display: 'inline-flex', alignItems: 'center' }}>
1439
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1440
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1441
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1442
+ </svg>
1443
+ </button>
1444
+ </div>
1445
+ )}
1446
+ </div>
1447
+ )}
1448
+ <textarea
1449
+ id="chat-input"
1450
+ value={message}
1451
+ onChange={(event) => {
1452
+ resizeInput(event.currentTarget)
1453
+ onSetMessage(event.target.value)
1454
+ }}
1455
+ onKeyDown={handleInputKeyDown}
1456
+ onDrop={handleWorkspaceDrop}
1457
+ placeholder="Ask anything, @ to mention, / for actions"
1458
+ rows={1}
1459
+ />
1460
+ <div className="chat-tool-menu-wrap" ref={toolMenuRef}>
1461
+ <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' }}>
1462
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1463
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1464
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1465
+ </svg>
1466
+ </button>
1467
+ {toolMenuOpen && (
1468
+ <div className="chat-tool-menu" role="menu">
1469
+ <button type="button" role="menuitem" onClick={openImagePicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1470
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1471
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1472
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
1473
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
1474
+ <polyline points="21 15 16 10 5 21"></polyline>
1475
+ </svg>
1476
+ </span>
1477
+ <span>Add image</span>
1478
+ </button>
1479
+ <button type="button" role="menuitem" onClick={openDocumentPicker} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1480
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1481
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1482
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
1483
+ <polyline points="14 2 14 8 20 8"></polyline>
1484
+ </svg>
1485
+ </span>
1486
+ <span>Add file</span>
1487
+ </button>
1488
+ <button type="button" role="menuitem" onClick={startWebSearch} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1489
+ <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
1490
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1491
+ <circle cx="11" cy="11" r="8"></circle>
1492
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
1493
+ </svg>
1494
+ </span>
1495
+ <span>Search web</span>
1496
+ </button>
1497
+ </div>
1498
+ )}
1499
+ </div>
1500
+ <input id="vision-file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={onSelectImage} style={{ display: 'none' }} />
1501
+ <input id="document-file-input" type="file" accept="application/pdf,.pdf" onChange={onSelectDocument} style={{ display: 'none' }} />
1502
+ <button id="screen-capture-btn" type="button" onClick={onCaptureScreen} aria-label="Capture screen">
1503
+ <span className="screen-capture-eye" aria-hidden="true" />
1504
+ </button>
1505
+ <div className="chat-provider-select" style={{ display: 'flex', gap: '4px', padding: 0, background: 'transparent', border: 0, width: '100%', height: '32px' }}>
1506
+ <select
1507
+ value={status?.activeProvider ?? ''}
1508
+ onChange={(event) => onSetProvider(event.target.value)}
1509
+ style={{
1510
+ flex: 1,
1511
+ minWidth: '65px',
1512
+ height: '100%',
1513
+ padding: '0 20px 0 6px',
1514
+ background: 'transparent',
1515
+ border: 0,
1516
+ color: 'var(--text-soft)',
1517
+ fontSize: '0.78rem',
1518
+ outline: 'none',
1519
+ cursor: 'pointer',
1520
+ fontFamily: 'inherit',
1521
+ textOverflow: 'ellipsis',
1522
+ whiteSpace: 'nowrap'
1523
+ }}
1524
+ >
1525
+ {status?.availableProviders.map((provider) => {
1526
+ let displayName = provider
1527
+ if (provider === 'gemini') displayName = 'Gemini'
1528
+ else if (provider === 'openai') displayName = 'OpenAI'
1529
+ else if (provider === 'openrouter') displayName = 'OpenRouter'
1530
+ else if (provider === 'deepseek') displayName = 'DeepSeek'
1531
+ else if (provider === 'anthropic') displayName = 'Claude'
1532
+ else if (provider === 'huggingface') displayName = 'HF'
1533
+ else if (provider === 'local_openai') displayName = 'Local'
1534
+ else if (provider === 'ollama') displayName = 'Ollama'
1535
+ return <option key={provider} value={provider}>{displayName}</option>
1536
+ })}
1537
+ </select>
1538
+ {availableModels.length > 0 && (
1539
+ <select
1540
+ value={activeModel}
1541
+ onChange={(event) => onSetModel(event.target.value)}
1542
+ style={{
1543
+ flex: 1.2,
1544
+ minWidth: '85px',
1545
+ height: '100%',
1546
+ padding: '0 20px 0 6px',
1547
+ background: 'transparent',
1548
+ border: 0,
1549
+ color: 'var(--text-soft)',
1550
+ fontSize: '0.78rem',
1551
+ outline: 'none',
1552
+ cursor: 'pointer',
1553
+ fontFamily: 'inherit',
1554
+ textOverflow: 'ellipsis',
1555
+ whiteSpace: 'nowrap'
1556
+ }}
1557
+ >
1558
+ {availableModels.map((model) => (
1559
+ <option key={model} value={model}>{model.split('/').pop()}</option>
1560
+ ))}
1561
+ {!availableModels.includes(activeModel) && activeModel && (
1562
+ <option value={activeModel}>{activeModel.split('/').pop()}</option>
1563
+ )}
1564
+ </select>
1565
+ )}
1566
+ </div>
1567
+ <button
1568
+ id="mic-btn"
1569
+ className={`${isRecording ? 'is-recording' : ''} ${voiceMode ? 'voice-mode-active' : ''}`}
1570
+ type="button"
1571
+ onClick={toggleRecording}
1572
+ title={voiceMode ? 'ปิดโหมดสนทนาเสียง' : 'เปิดโหมดสนทนาเสียง'}
1573
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
1574
+ >
1575
+ {isRecording ? (
1576
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1577
+ <rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
1578
+ </svg>
1579
+ ) : (
1580
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1581
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
1582
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
1583
+ <line x1="12" y1="19" x2="12" y2="23"></line>
1584
+ <line x1="8" y1="23" x2="16" y2="23"></line>
1585
+ </svg>
1586
+ )}
1587
+ </button>
1588
+ <button id="send-btn" type="submit" disabled={sending || !canSubmit} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
1589
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1590
+ <line x1="22" y1="2" x2="11" y2="13"></line>
1591
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
1592
+ </svg>
1593
+ </button>
1594
+ </form>
1595
+ </div>
1596
+ </section>
1597
+ )
1598
+ }