@pheem49/mint 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/Cargo.lock +3 -3
  2. package/Cargo.toml +1 -1
  3. package/README.md +0 -17
  4. package/Release_Note.md +137 -58
  5. package/bin/mint +0 -0
  6. package/crates/mint-cli/src/agent.rs +150 -45
  7. package/crates/mint-cli/src/image.rs +8 -8
  8. package/crates/mint-cli/src/main.rs +929 -412
  9. package/crates/mint-cli/src/onboard.rs +108 -108
  10. package/crates/mint-cli/src/setup.rs +143 -143
  11. package/crates/mint-core/src/agent_loop.rs +1 -0
  12. package/crates/mint-core/src/api_server.rs +108 -52
  13. package/crates/mint-core/src/browser.rs +56 -1
  14. package/crates/mint-core/src/channels.rs +1 -0
  15. package/crates/mint-core/src/chat.rs +126 -8
  16. package/crates/mint-core/src/code_tools.rs +18 -20
  17. package/crates/mint-core/src/config.rs +245 -17
  18. package/crates/mint-core/src/image_gen.rs +5 -5
  19. package/crates/mint-core/src/lib.rs +16 -2
  20. package/crates/mint-core/src/memory.rs +6 -1
  21. package/crates/mint-core/src/orchestration.rs +260 -72
  22. package/crates/mint-core/src/pictures.rs +3 -4
  23. package/crates/mint-core/src/semantic.rs +1 -1
  24. package/crates/mint-core/src/shell.rs +1 -1
  25. package/crates/mint-core/src/skills.rs +46 -19
  26. package/crates/mint-core/src/tts.rs +3 -1
  27. package/flatpak/com.pheem49.mint.desktop +9 -0
  28. package/flatpak/com.pheem49.mint.metainfo.xml +35 -0
  29. package/flatpak/com.pheem49.mint.yaml +52 -0
  30. package/package.json +1 -1
  31. package/shared-modules/.github/workflows/ci.yml +120 -0
  32. package/shared-modules/.github/workflows/update.yaml +28 -0
  33. package/shared-modules/CODEOWNERS +23 -0
  34. package/shared-modules/README.md +79 -0
  35. package/shared-modules/SDL/README.md +3 -0
  36. package/shared-modules/SDL/SDL-1.2.15.json +51 -0
  37. package/shared-modules/SDL/SDL_Pango-0.1.2-API-adds.patch +118 -0
  38. package/shared-modules/SDL/SDL_image-1.2.12.json +25 -0
  39. package/shared-modules/SDL/SDL_mixer-1.2.12.json +27 -0
  40. package/shared-modules/SDL/SDL_net-1.2.8.json +19 -0
  41. package/shared-modules/SDL/SDL_pango-0.1.2.json +26 -0
  42. package/shared-modules/SDL/SDL_ttf-2.0.11.json +28 -0
  43. package/shared-modules/SDL/libsdl-1.2.15-strict-prototypes.patch +316 -0
  44. package/shared-modules/SDL/sdl-check-for-SDL_VIDEO_X11_BACKINGSTORE.patch +24 -0
  45. package/shared-modules/SDL/sdl-libx11-build.patch +59 -0
  46. package/shared-modules/SDL/sdl-sysloadso-buffer-length.patch +27 -0
  47. package/shared-modules/SDL/sdl12-compat.json +22 -0
  48. package/shared-modules/SDL2/SDL2_image.json +27 -0
  49. package/shared-modules/SDL2/SDL2_ttf.json +29 -0
  50. package/shared-modules/cld2/CMakeLists.txt +157 -0
  51. package/shared-modules/cld2/cld2.json +24 -0
  52. package/shared-modules/clutter/clutter.json +72 -0
  53. package/shared-modules/dbus-glib/dbus-glib.json +29 -0
  54. package/shared-modules/glew/glew.json +36 -0
  55. package/shared-modules/glu/glu-9.json +12 -0
  56. package/shared-modules/gtk2/arc-gtk2-theme-Replace-Inkscape-dependency-with-rsvg-convert.patch +180 -0
  57. package/shared-modules/gtk2/gtk2-common-themes.json +360 -0
  58. package/shared-modules/gtk2/gtk2-fix-crash-in-show-uri.patch +30 -0
  59. package/shared-modules/gtk2/gtk2-gcc14.patch +281 -0
  60. package/shared-modules/gtk2/gtk2-gcc15.patch +250 -0
  61. package/shared-modules/gtk2/gtk2-murrine-c99.patch +34 -0
  62. package/shared-modules/gtk2/gtk2-use-adwaita-theme.patch +80 -0
  63. package/shared-modules/gtk2/gtk2.json +120 -0
  64. package/shared-modules/gtk2/ibus-gcc15.patch +13 -0
  65. package/shared-modules/gtk2/murrine-engine-fix-crash.patch +12 -0
  66. package/shared-modules/gtk2/readme.md +10 -0
  67. package/shared-modules/intltool/intltool-0.51.json +20 -0
  68. package/shared-modules/intltool/intltool-perl5.26-regex-fixes.patch +59 -0
  69. package/shared-modules/lame/0001-Build-xmm_quantize_sub.c-with-msse.patch +34 -0
  70. package/shared-modules/lame/0002-Include-GTK-1-autoconf-directives-in-build-system.patch +222 -0
  71. package/shared-modules/lame/0003-Patch-out-remaining-ansi2knr.patch +70 -0
  72. package/shared-modules/lame/0004-initscr-is-not-used-anywhere-in-lame-sourcetree.patch +41 -0
  73. package/shared-modules/lame/0005-Update-autoconf.patch +81 -0
  74. package/shared-modules/lame/lame-3.99.5.json +32 -0
  75. package/shared-modules/libappindicator/libappindicator-gtk2-12.10.json +140 -0
  76. package/shared-modules/libappindicator/libappindicator-gtk3-12.10.json +140 -0
  77. package/shared-modules/libappindicator/libappindicator-gtk3-introspection-12.10.json +140 -0
  78. package/shared-modules/libappindicator/patches/0001-libappindicator-ftbfs.patch +20 -0
  79. package/shared-modules/libappindicator/patches/0002-libappindicator-no-python.patch +888 -0
  80. package/shared-modules/libappindicator/patches/0003-libappindicator-fix-crash-from-incorrect-signal-emission.patch +74 -0
  81. package/shared-modules/libappindicator/patches/0004-libappindicator-fix-crash-iterating-icon-themes.patch +11 -0
  82. package/shared-modules/libayatana-appindicator/0001-Fix-HAVE_VALGRIND-AM_CONDITIONAL.patch +51 -0
  83. package/shared-modules/libayatana-appindicator/0001-Make-introspection-configurable.patch +44 -0
  84. package/shared-modules/libayatana-appindicator/libayatana-appindicator-gtk3.json +121 -0
  85. package/shared-modules/libcanberra/0001-gtk-Don-t-assume-all-GdkDisplays-are-GdkX11Displays-.patch +123 -0
  86. package/shared-modules/libcanberra/libcanberra.json +44 -0
  87. package/shared-modules/libmad/libmad.json +19 -0
  88. package/shared-modules/libsecret/libsecret.json +23 -0
  89. package/shared-modules/libsoup/libsoup-2.4.json +14 -0
  90. package/shared-modules/libusb/libusb.json +27 -0
  91. package/shared-modules/linux-audio/dssi.json +31 -0
  92. package/shared-modules/linux-audio/fftw3f-static.json +44 -0
  93. package/shared-modules/linux-audio/fftw3f.json +42 -0
  94. package/shared-modules/linux-audio/fluidsynth2-static.json +36 -0
  95. package/shared-modules/linux-audio/fluidsynth2.json +25 -0
  96. package/shared-modules/linux-audio/ladspa.json +26 -0
  97. package/shared-modules/linux-audio/lash.json +35 -0
  98. package/shared-modules/linux-audio/libinstpatch.json +16 -0
  99. package/shared-modules/linux-audio/liblo-static.json +22 -0
  100. package/shared-modules/linux-audio/liblo.json +17 -0
  101. package/shared-modules/linux-audio/lilv-static.json +94 -0
  102. package/shared-modules/linux-audio/lilv.json +89 -0
  103. package/shared-modules/linux-audio/lrdf.json +57 -0
  104. package/shared-modules/linux-audio/lv2.json +20 -0
  105. package/shared-modules/linux-audio/patches/lash/0001-configure-remove-aclocal-m4.patch +29 -0
  106. package/shared-modules/linux-audio/patches/lash/0002-Fix-build-with-recent-gcc.patch +73 -0
  107. package/shared-modules/linux-audio/patches/lash/0003-Fix-linking.patch +136 -0
  108. package/shared-modules/linux-audio/patches/raptor-libxml.patch +31 -0
  109. package/shared-modules/linux-audio/patches/stk-install.sh +54 -0
  110. package/shared-modules/linux-audio/stk.json +28 -0
  111. package/shared-modules/lua5.1/0004-Fix-stack-overflow-in-vararg-functions.patch +21 -0
  112. package/shared-modules/lua5.1/lua-5.1.5-so.patch +43 -0
  113. package/shared-modules/lua5.1/lua-5.1.5.json +42 -0
  114. package/shared-modules/lua5.3/lua-5.3.5.json +41 -0
  115. package/shared-modules/lua5.3/lua.pc.in +8 -0
  116. package/shared-modules/lua5.3/luaconf.patch +47 -0
  117. package/shared-modules/lua5.3/makefile-add-liblua.so-rule.patch +47 -0
  118. package/shared-modules/lua5.4/lua-5.4.json +41 -0
  119. package/shared-modules/lua5.4/lua.pc.in +8 -0
  120. package/shared-modules/lua5.4/luaconf.patch +34 -0
  121. package/shared-modules/lua5.4/makefile-add-liblua.so-rule.patch +42 -0
  122. package/shared-modules/luajit/luajit.json +30 -0
  123. package/shared-modules/lzo/lzo.json +27 -0
  124. package/shared-modules/mac/mac.json +18 -0
  125. package/shared-modules/physfs/physfs.json +26 -0
  126. package/shared-modules/pygame/audiofile-gcc6.patch +21 -0
  127. package/shared-modules/pygame/cython-fixes.patch +34 -0
  128. package/shared-modules/pygame/fluidsynth-no-rawmidi.patch +69 -0
  129. package/shared-modules/pygame/glib.patch +25 -0
  130. package/shared-modules/pygame/pygame1-python2.json +157 -0
  131. package/shared-modules/pygtk/pygtk-c99.patch +15 -0
  132. package/shared-modules/pygtk/pygtk.json +117 -0
  133. package/shared-modules/pygtk/pygtk.patch +39 -0
  134. package/shared-modules/python2.7/python-2.7.json +48 -0
  135. package/shared-modules/smpeg/smpeg-0.4.5.json +50 -0
  136. package/shared-modules/smpeg/smpeg-am-prog-as.patch +12 -0
  137. package/shared-modules/smpeg/smpeg-export-mpegaudio-class.patch +17 -0
  138. package/shared-modules/smpeg/smpeg-gcc6.patch +40 -0
  139. package/shared-modules/smpeg/smpeg-no-gtk.patch +37 -0
  140. package/shared-modules/squashfs-tools/squashfs-tools.json +25 -0
  141. package/shared-modules/update-checker.sh +50 -0
  142. package/shared-modules/vorbisgain/0001-temp_files.patch +75 -0
  143. package/shared-modules/vorbisgain/0002-errno.patch +33 -0
  144. package/shared-modules/vorbisgain/0003-manpage.patch +248 -0
  145. package/shared-modules/vorbisgain/0004-vorbisgain_mtime.patch +183 -0
  146. package/shared-modules/vorbisgain/0005-double_fclose.patch +21 -0
  147. package/shared-modules/vorbisgain/0006-manpage_hyphens.patch +56 -0
  148. package/shared-modules/vorbisgain/0007-recursively_spelling.patch +14 -0
  149. package/shared-modules/vorbisgain/0008-manpage_recursion_mistake.patch +23 -0
  150. package/shared-modules/vorbisgain/0009-hardening.patch +13 -0
  151. package/shared-modules/vorbisgain/0010-fclose.patch +13 -0
  152. package/shared-modules/vorbisgain/README.md +23 -0
  153. package/shared-modules/vorbisgain/vorbisgain_0.37-2.json +29 -0
  154. package/src/renderer/shared/agentProgress.ts +145 -0
  155. package/src/renderer/shared/calculator.ts +52 -0
  156. package/src/renderer/shared/components/AgentActivityDrawer.tsx +72 -0
  157. package/src/renderer/shared/components/AgentActivityTable.tsx +85 -0
  158. package/src/renderer/shared/components/ApprovalCard.tsx +93 -0
  159. package/src/renderer/shared/components/ChatCodeBlock.tsx +75 -0
  160. package/src/renderer/shared/components/ThinkingBlock.tsx +101 -0
  161. package/src/renderer/shared/constants/models.ts +91 -0
  162. package/src/renderer/shared/platform.ts +82 -0
  163. package/src/renderer/shared/types.ts +140 -0
  164. package/src/renderer/shared/utils/agentActivity.ts +187 -0
  165. package/src/renderer/shared/utils/approval.ts +1 -0
  166. package/src/renderer/shared/utils/approval.tsx +64 -0
  167. package/src/renderer/shared/utils/fileIcons.ts +135 -0
  168. package/src/renderer/shared/utils/markdown.tsx +347 -0
  169. package/src/renderer/shared/utils/providers.ts +36 -0
  170. package/src/renderer/shared/utils/speech.ts +216 -0
  171. package/src/renderer/shared/utils/ui.ts +147 -0
  172. package/src/renderer/src/agentProgress.ts +11 -39
  173. package/src/renderer/src/calculator.ts +3 -46
  174. package/src/renderer/src/components/ChatPanel.tsx +219 -1308
  175. package/src/renderer/src/components/MintDashboard.tsx +67 -170
  176. package/src/renderer/src/components/PicturesLibrary.tsx +32 -8
  177. package/src/renderer/src/components/Settings/AgentsTab.tsx +381 -0
  178. package/src/renderer/src/components/Settings/AutomationTab.tsx +0 -22
  179. package/src/renderer/src/components/Settings/GeneralTab.tsx +256 -2
  180. package/src/renderer/src/components/SettingsWindow.tsx +120 -3
  181. package/src/renderer/src/components/WorkspacePanel.tsx +7 -125
  182. package/src/renderer/src/css/styles.css +100 -0
  183. package/src/renderer/src/tauri.ts +128 -143
  184. package/src/renderer/src-web/agentProgress.ts +11 -39
  185. package/src/renderer/src-web/calculator.ts +3 -46
  186. package/src/renderer/src-web/components/ChatPanel.tsx +219 -1306
  187. package/src/renderer/src-web/components/MintDashboard.tsx +42 -170
  188. package/src/renderer/src-web/components/PicturesLibrary.tsx +23 -29
  189. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +381 -0
  190. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +0 -22
  191. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +254 -3
  192. package/src/renderer/src-web/components/SettingsWindow.tsx +127 -3
  193. package/src/renderer/src-web/css/styles.css +100 -0
  194. package/src/renderer/src-web/tauri.ts +180 -102
  195. package/src-tauri/src/desktop.rs +18 -16
  196. package/src-tauri/src/discord_rpc.rs +1 -1
  197. package/src-tauri/src/lib.rs +168 -40
  198. package/src-tauri/src/webhooks.rs +1 -0
  199. package/src-tauri/src/workflows.rs +1 -0
  200. package/src-tauri/tauri.conf.json +1 -1
@@ -1,5 +1,30 @@
1
1
  import { useEffect, useRef, useState, Fragment, type ChangeEvent, type ClipboardEvent, type FormEvent, type KeyboardEvent, type RefObject, type DragEvent } from 'react'
2
- import { hasAgentToolActivity, thoughtsFrom } from '../agentProgress'
2
+ import { hasAgentToolActivity, thoughtsFrom, parseFileChangesFromProgress } from '../agentProgress'
3
+ import {
4
+ GEMINI_MODELS,
5
+ OPENAI_MODELS,
6
+ ANTHROPIC_MODELS,
7
+ OPENROUTER_MODELS,
8
+ DEEPSEEK_MODELS,
9
+ HF_MODELS,
10
+ LOCAL_MODELS,
11
+ OLLAMA_MODELS,
12
+ } from '../../shared/constants/models'
13
+ import { badge, providerLabel, fallbackNotice } from '../../shared/utils/providers'
14
+ import { activitiesFrom, parseWebSearchSources, type AgentActivity, type AgentActivityView } from '../../shared/utils/agentActivity'
15
+ import { AgentActivityTable } from '../../shared/components/AgentActivityTable'
16
+ import { ChatCodeBlock } from '../../shared/components/ChatCodeBlock'
17
+ import { renderApprovalDetails, renderDiff, type ApprovalDetails } from '../../shared/utils/approval'
18
+ import { ApprovalCard } from '../../shared/components/ApprovalCard'
19
+ import { renderFormattedMessage, readableAssistantText, cleanSpeechText, renderSpeakerIcon } from '../../shared/utils/markdown'
20
+ import { ThinkingBlock } from '../../shared/components/ThinkingBlock'
21
+ import { AgentActivityDrawer } from '../../shared/components/AgentActivityDrawer'
22
+ import type { DiffHunk, FileChange } from '../../shared/types'
23
+ import { numericSetting } from '../../shared/utils/ui'
24
+ import { useSpeechToText } from '../../shared/utils/speech'
25
+
26
+
27
+
3
28
  import {
4
29
  type AgentProgress,
5
30
  type ChatResponse,
@@ -7,364 +32,6 @@ import {
7
32
  getTtsUrls,
8
33
  } from '../tauri'
9
34
 
10
- const GEMINI_MODELS = [
11
- 'gemini-2.5-flash',
12
- 'gemini-2.5-pro',
13
- 'gemini-2.0-flash',
14
- 'gemini-1.5-flash',
15
- 'gemini-1.5-pro',
16
- 'gemini-3.1-flash-lite',
17
- 'gemini-3.1-flash-lite-preview'
18
- ]
19
-
20
- const OPENAI_MODELS = [
21
- 'gpt-4o',
22
- 'gpt-4o-mini',
23
- 'o1',
24
- 'o3-mini',
25
- 'o1-preview',
26
- 'o1-mini',
27
- 'gpt-4-turbo'
28
- ]
29
-
30
- const OPENROUTER_MODELS = [
31
- 'openai/gpt-4o-mini',
32
- 'openai/gpt-4o',
33
- 'anthropic/claude-3.5-sonnet',
34
- 'anthropic/claude-3.5-haiku',
35
- 'google/gemini-2.5-flash',
36
- 'meta-llama/llama-3.3-70b-instruct',
37
- 'mistralai/mistral-large'
38
- ]
39
-
40
- const DEEPSEEK_MODELS = [
41
- 'deepseek-v4-flash',
42
- 'deepseek-v4-pro',
43
- 'deepseek-chat',
44
- 'deepseek-reasoner'
45
- ]
46
-
47
- const ANTHROPIC_MODELS = [
48
- 'claude-3-7-sonnet-latest',
49
- 'claude-3-5-sonnet-latest',
50
- 'claude-3-5-haiku-latest',
51
- 'claude-3-opus-latest'
52
- ]
53
-
54
- const HF_MODELS = [
55
- 'meta-llama/Llama-3.3-70B-Instruct',
56
- 'meta-llama/Meta-Llama-3-8B-Instruct',
57
- 'meta-llama/Llama-3.2-3B-Instruct',
58
- 'Qwen/Qwen2.5-72B-Instruct',
59
- 'Qwen/Qwen2.5-Coder-32B-Instruct',
60
- 'mistralai/Mistral-7B-Instruct-v0.3',
61
- 'google/gemma-2-9b-it'
62
- ]
63
-
64
- const LOCAL_MODELS = [
65
- 'local-model',
66
- 'Qwen/Qwen2.5-7B-Instruct-GGUF',
67
- 'meta-llama/Llama-3.2-3B-Instruct-GGUF',
68
- 'lmstudio-community/gemma-2-9b-it-GGUF'
69
- ]
70
-
71
- const OLLAMA_MODELS: string[] = []
72
-
73
- interface ApprovalDetails {
74
- title: string
75
- body: string
76
- reason?: string
77
- isDangerous: boolean
78
- }
79
-
80
- function badge(provider: string, model: string) {
81
- return [provider, model].filter(Boolean).join(' / ')
82
- }
83
-
84
- function providerLabel(provider: string) {
85
- switch (provider) {
86
- case 'gemini':
87
- return 'Gemini'
88
- case 'openai':
89
- return 'OpenAI'
90
- case 'openrouter':
91
- return 'OpenRouter'
92
- case 'deepseek':
93
- return 'DeepSeek'
94
- case 'anthropic':
95
- return 'Claude'
96
- case 'huggingface':
97
- return 'Hugging Face'
98
- case 'local_openai':
99
- return 'Local OpenAI'
100
- case 'ollama':
101
- return 'Ollama'
102
- default:
103
- return provider || 'Primary provider'
104
- }
105
- }
106
-
107
- function fallbackNotice(response: Pick<ChatResponse, 'provider' | 'fallbackProvider'> | null | undefined) {
108
- if (!response?.fallbackProvider) return ''
109
- return `${providerLabel(response.fallbackProvider)} unavailable, fell back to ${providerLabel(response.provider)}.`
110
- }
111
-
112
- interface AgentActivity {
113
- label: string
114
- target: string
115
- kind: 'file' | 'folder' | 'search' | 'terminal' | 'tool'
116
- state: 'active' | 'done' | 'error'
117
- action?: string
118
- }
119
-
120
- interface AgentActivityView {
121
- summary: string
122
- items: AgentActivity[]
123
- }
124
-
125
- function activityDetail(input: Record<string, unknown>, key: string) {
126
- const value = input[key]
127
- return typeof value === 'string' && value.trim() ? value : ''
128
- }
129
-
130
- function formatActivityTarget(value: string) {
131
- const compact = value.replace(/^\/home\/([^/]+)/, '~')
132
- return compact || 'workspace'
133
- }
134
-
135
- function activityKind(action: string, target: string): AgentActivity['kind'] {
136
- if (['search_code', 'semantic_search', 'knowledge_search', 'web_search', 'memory_recall'].includes(action)) return 'search'
137
- if (['run_shell', 'verify'].includes(action)) return 'terminal'
138
- if (['list_files', 'detect_project'].includes(action)) return 'folder'
139
- if (['read_file', 'symbols', 'read_diagnostics', 'git_diff', 'apply_patch', 'write_file', 'note_write', 'view_image'].includes(action)) return 'file'
140
- return target.includes('/') && !/\.[^/]+$/.test(target) ? 'folder' : 'tool'
141
- }
142
-
143
- function describeTool(action: string, input: Record<string, unknown>): AgentActivity {
144
- const path = activityDetail(input, 'path')
145
- const query = activityDetail(input, 'query')
146
- const command = activityDetail(input, 'command')
147
- const name = activityDetail(input, 'name')
148
- const tool = activityDetail(input, 'tool')
149
- const rawTarget = path || query || command || name || tool || action.replaceAll('_', ' ')
150
-
151
- // Append line range for read_file when startLine / endLine are available
152
- let target = rawTarget
153
- if (action === 'read_file' && path) {
154
- const startLine = typeof input.startLine === 'number' ? input.startLine : undefined
155
- const endLine = typeof input.endLine === 'number' ? input.endLine : undefined
156
- if (startLine !== undefined && endLine !== undefined) {
157
- target = `${rawTarget} #L${startLine}-${endLine}`
158
- } else if (startLine !== undefined) {
159
- target = `${rawTarget} #L${startLine}`
160
- }
161
- }
162
-
163
- const labels: Record<string, string> = {
164
- apply_patch: 'Applying patch',
165
- ask_user: 'Asking user',
166
- create_plan: 'Creating plan',
167
- detect_project: 'Detecting project',
168
- git_branch: 'Reading branch',
169
- git_diff: 'Reading diff',
170
- git_log: 'Reading log',
171
- git_status: 'Reading git status',
172
- knowledge_search: 'Searching knowledge',
173
- list_files: 'Listing files',
174
- list_tests: 'Listing tests',
175
- mcp_tool: 'Calling MCP tool',
176
- memory_recall: 'Recalling memory',
177
- note_write: 'Writing note',
178
- read_diagnostics: 'Reading diagnostics',
179
- read_file: 'Reading file',
180
- request_user_approval: 'Requesting approval',
181
- run_plugin: 'Running plugin',
182
- run_shell: 'Running command',
183
- search_code: 'Searching code',
184
- semantic_index: 'Indexing code',
185
- semantic_search: 'Searching code',
186
- symbols: 'Inspecting symbols',
187
- update_plan: 'Updating plan',
188
- verify: 'Verifying',
189
- view_image: 'Viewing image',
190
- web_search: 'Searching web',
191
- write_file: 'Writing file',
192
- }
193
- return {
194
- label: labels[action] ?? 'Using tool',
195
- target: formatActivityTarget(target),
196
- kind: activityKind(action, target),
197
- state: 'active',
198
- action,
199
- }
200
- }
201
-
202
- function activitySummary(items: AgentActivity[]) {
203
- const files = new Set<string>()
204
- const folders = new Set<string>()
205
- for (const item of items) {
206
- if (item.kind === 'file') files.add(item.target)
207
- if (item.kind === 'folder') folders.add(item.target)
208
- }
209
- const parts = [
210
- files.size ? `${files.size} ${files.size === 1 ? 'file' : 'files'}` : '',
211
- folders.size ? `${folders.size} ${folders.size === 1 ? 'folder' : 'folders'}` : '',
212
- ].filter(Boolean)
213
- return parts.length ? `Exploring ${parts.join(', ')}` : 'Working through task'
214
- }
215
-
216
- function activitiesFrom(progress: AgentProgress[]): AgentActivityView {
217
- const activities: AgentActivity[] = []
218
- for (const event of progress) {
219
- if (event.type === 'ToolStart') {
220
- activities.push(describeTool(event.data.action, event.data.input))
221
- } else if (event.type === 'ToolEnd') {
222
- for (let index = activities.length - 1; index >= 0; index -= 1) {
223
- if (activities[index].state !== 'active') continue
224
- activities[index].state = event.data.result.startsWith('Error:') ? 'error' : 'done'
225
- if (activities[index].state === 'error') {
226
- activities[index].label = 'Failed'
227
- }
228
- if (activities[index].action === 'ask_user' && event.data.result.startsWith('User answered:')) {
229
- const answer = event.data.result.replace('User answered:', '').trim()
230
- activities[index].target = `(Answered: "${answer}") ${activities[index].target}`
231
- }
232
- break
233
- }
234
- }
235
- }
236
- const items = activities.slice(-12)
237
- return { summary: activitySummary(activities), items }
238
- }
239
-
240
- function AgentActivityTable({ activityView }: { activityView: AgentActivityView }) {
241
- const [expandedIndices, setExpandedIndices] = useState<Record<number, boolean>>({})
242
-
243
- const toggleExpand = (index: number) => {
244
- setExpandedIndices(prev => ({
245
- ...prev,
246
- [index]: !prev[index]
247
- }))
248
- }
249
-
250
- return (
251
- <div className="agent-activity-list">
252
- <div className="agent-activity-table-head" aria-hidden="true">
253
- <span>Tool</span>
254
- <span />
255
- <span>Target</span>
256
- <span />
257
- </div>
258
- {activityView.items.map((activity, index) => {
259
- const isExpanded = !!expandedIndices[index]
260
- return (
261
- <div
262
- className="agent-activity-item"
263
- data-kind={activity.kind}
264
- data-state={activity.state}
265
- key={`${index}-${activity.label}-${activity.target}`}
266
- style={{ cursor: 'pointer' }}
267
- onClick={() => toggleExpand(index)}
268
- >
269
- <span className="agent-activity-label">{activity.label}</span>
270
- <span className="agent-activity-icon" aria-hidden="true" />
271
- <span
272
- className="agent-activity-text"
273
- style={isExpanded ? { whiteSpace: 'normal', wordBreak: 'break-all', overflow: 'visible', textOverflow: 'clip' } : undefined}
274
- >
275
- {activity.target}
276
- </span>
277
- <span
278
- className="agent-activity-chevron"
279
- style={{
280
- transform: isExpanded ? 'rotate(90deg)' : 'none',
281
- transition: 'transform 0.2s ease',
282
- display: 'inline-block'
283
- }}
284
- aria-hidden="true"
285
- >
286
- &gt;
287
- </span>
288
- </div>
289
- )
290
- })}
291
- </div>
292
- )
293
- }
294
-
295
- function renderApprovalDetails(approval: any): ApprovalDetails {
296
- if (!approval) return { title: 'Action Pending Approval', body: 'No action details available.', isDangerous: false }
297
- 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 }
298
- 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 }
299
- if (approval.RunShell) return { title: 'Run Shell Command', body: approval.RunShell.command, reason: 'Executing shell commands can modify your system.', isDangerous: true }
300
- if (approval.NoteWrite) return { title: 'Write Note', body: `Path: ${approval.NoteWrite.path}`, reason: 'Creating or updating workspace notes.', isDangerous: false }
301
- if (approval.RunPlugin) return { title: `Run Plugin: ${approval.RunPlugin.name}`, body: approval.RunPlugin.instruction, reason: 'Executing a native plugin action.', isDangerous: false }
302
- if (approval.McpTool) {
303
- const { server, tool, arguments: args } = approval.McpTool
304
- return { title: `Run MCP Tool: ${server}/${tool}`, body: typeof args === 'string' ? args : JSON.stringify(args, null, 2), reason: 'Running external MCP tool.', isDangerous: false }
305
- }
306
- if (approval.UserApproval) return { title: approval.UserApproval.title, body: approval.UserApproval.prompt, reason: 'The agent requested explicit approval.', isDangerous: false }
307
- if (approval.AskUser) return { title: 'Question From Agent', body: approval.AskUser.question, reason: 'Type your answer below and submit to respond to the agent.', isDangerous: false }
308
- return { title: 'Unknown Action', body: JSON.stringify(approval, null, 2), reason: 'Requires approval to proceed.', isDangerous: false }
309
- }
310
-
311
- function renderDiff(diffText: string) {
312
- if (!diffText) return null
313
- const lines = diffText.split('\n')
314
- return (
315
- <div style={{
316
- background: '#0b0f19',
317
- borderRadius: '6px',
318
- padding: '8px',
319
- border: '1px solid rgba(255, 255, 255, 0.08)',
320
- overflowX: 'auto',
321
- maxHeight: '400px',
322
- fontFamily: 'monospace',
323
- fontSize: '0.74rem',
324
- lineHeight: '1.4',
325
- }}>
326
- {lines.map((line, idx) => {
327
- let style: any = {
328
- whiteSpace: 'pre-wrap',
329
- padding: '2px 6px',
330
- }
331
-
332
- if (line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')) {
333
- style = {
334
- ...style,
335
- color: '#64748b',
336
- fontWeight: 'bold',
337
- }
338
- } else if (line.startsWith('+')) {
339
- style = {
340
- ...style,
341
- background: 'rgba(16, 185, 129, 0.12)',
342
- borderLeft: '3px solid #10b981',
343
- color: '#a7f3d0'
344
- }
345
- } else if (line.startsWith('-')) {
346
- style = {
347
- ...style,
348
- background: 'rgba(239, 68, 68, 0.12)',
349
- borderLeft: '3px solid #ef4444',
350
- color: '#fca5a5'
351
- }
352
- } else {
353
- style = {
354
- ...style,
355
- color: '#e2e8f0',
356
- }
357
- }
358
-
359
- return (
360
- <div key={idx} style={style}>
361
- {line}
362
- </div>
363
- )
364
- })}
365
- </div>
366
- )
367
- }
368
35
 
369
36
  interface ChatPanelProps {
370
37
  interactions: any[]
@@ -390,7 +57,6 @@ interface ChatPanelProps {
390
57
  onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
391
58
  onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
392
59
  onPasteImage: (clipboardData: DataTransfer) => boolean
393
- onReadClipboardImage: () => Promise<boolean>
394
60
  onSetMessage: (message: string) => void
395
61
  onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
396
62
  onRemoveImage: (idx: number) => void
@@ -404,587 +70,9 @@ interface ChatPanelProps {
404
70
  onToggleMobileSidebar: () => void
405
71
  settingsConfig: any
406
72
  onSetModel: (model: string) => void
73
+ onCancelMessage: () => void
407
74
  }
408
75
 
409
- function ChatCodeBlock({ code, language }: { code: string; language: string; key?: any }) {
410
- const [copied, setCopied] = useState(false)
411
-
412
- const handleCopy = async () => {
413
- try {
414
- await navigator.clipboard.writeText(code)
415
- setCopied(true)
416
- setTimeout(() => setCopied(false), 2000)
417
- } catch (err) {
418
- console.error('Failed to copy code: ', err)
419
- }
420
- }
421
-
422
- const handleDownload = () => {
423
- try {
424
- const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
425
- const url = URL.createObjectURL(blob)
426
- const link = document.createElement('a')
427
- link.href = url
428
- link.download = `code.${language === 'plaintext' ? 'txt' : language}`
429
- link.click()
430
- URL.revokeObjectURL(url)
431
- } catch (err) {
432
- console.error('Failed to download code: ', err)
433
- }
434
- }
435
-
436
- const displayLang = language.charAt(0).toUpperCase() + language.slice(1).toLowerCase()
437
-
438
- return (
439
- <div className="chat-code-block-container" style={{ whiteSpace: 'normal' }}>
440
- <div className="chat-code-block-header">
441
- <span className="chat-code-block-lang">{displayLang}</span>
442
- <div className="chat-code-block-actions">
443
- <button type="button" onClick={handleDownload} title="Download code" className="chat-code-action-btn">
444
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
445
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
446
- <polyline points="7 10 12 15 17 10" />
447
- <line x1="12" y1="15" x2="12" y2="3" />
448
- </svg>
449
- </button>
450
- <button type="button" onClick={handleCopy} title="Copy code" className="chat-code-action-btn">
451
- {copied ? (
452
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
453
- <polyline points="20 6 9 17 4 12" />
454
- </svg>
455
- ) : (
456
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
457
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
458
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
459
- </svg>
460
- )}
461
- </button>
462
- </div>
463
- </div>
464
- <pre className="chat-code-block-body">
465
- <code>{code}</code>
466
- </pre>
467
- </div>
468
- )
469
- }
470
-
471
- const isTableLine = (line: string): boolean => {
472
- const trimmed = line.trim()
473
- return trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.length > 1
474
- }
475
-
476
- function renderFormattedMessage(text: string) {
477
- const displayText = readableAssistantText(text)
478
- if (!displayText) return null
479
-
480
- const formatInline = (str: string) => {
481
- const codeParts = str.split(/`([\s\S]*?)`/g)
482
- return codeParts.map((codePart, codeIndex) => {
483
- if (codeIndex % 2 === 1) {
484
- return (
485
- <code key={`code-${codeIndex}`} className="chat-inline-code">
486
- {codePart}
487
- </code>
488
- )
489
- }
490
- const boldParts = codePart.split(/\*\*([\s\S]*?)\*\*/g)
491
- return boldParts.map((boldPart, boldIndex) => {
492
- if (boldIndex % 2 === 1) {
493
- return (
494
- <strong key={`bold-${boldIndex}`} className="chat-bold-highlight">
495
- {boldPart}
496
- </strong>
497
- )
498
- }
499
- const mentionParts = boldPart.split(/(@[\w\-\.\/]+)/g)
500
- return mentionParts.map((mentionPart, mentionIndex) => {
501
- if (mentionIndex % 2 === 1) {
502
- return (
503
- <span key={`mention-${mentionIndex}`} className="chat-mention">
504
- {mentionPart}
505
- </span>
506
- )
507
- }
508
- return mentionPart
509
- })
510
- })
511
- })
512
- }
513
-
514
- const renderHtmlTable = (tableLines: string[], key: string) => {
515
- const rows: string[][] = []
516
- for (const line of tableLines) {
517
- const trimmed = line.trim()
518
- if (!trimmed) continue
519
- const content = trimmed.slice(1, -1)
520
- const contentEscaped = content.replace(/\\\|/g, '\u0000')
521
- const cols = contentEscaped.split('|').map(s => s.replace(/\u0000/g, '|').trim())
522
- rows.push(cols)
523
- }
524
-
525
- if (rows.length === 0) return null
526
-
527
- let hasSeparator = false
528
- let separatorIdx = -1
529
- for (let i = 0; i < rows.length; i++) {
530
- const row = rows[i]
531
- if (row.length > 0 && row.every(col => /^[-\s:]+$/.test(col))) {
532
- hasSeparator = true
533
- separatorIdx = i
534
- break
535
- }
536
- }
537
-
538
- let headerRow: string[] | null = null
539
- const dataRows: string[][] = []
540
-
541
- if (hasSeparator) {
542
- if (separatorIdx > 0) {
543
- headerRow = rows[separatorIdx - 1]
544
- for (let i = 0; i < rows.length; i++) {
545
- if (i !== separatorIdx && i !== separatorIdx - 1) {
546
- dataRows.push(rows[i])
547
- }
548
- }
549
- } else {
550
- for (let i = 0; i < rows.length; i++) {
551
- if (i !== separatorIdx) {
552
- dataRows.push(rows[i])
553
- }
554
- }
555
- }
556
- } else {
557
- headerRow = rows[0]
558
- for (let i = 1; i < rows.length; i++) {
559
- dataRows.push(rows[i])
560
- }
561
- }
562
-
563
- return (
564
- <div key={key} className="chat-table-container" style={{
565
- overflowX: 'auto',
566
- margin: '14px 0',
567
- width: '100%',
568
- borderRadius: '8px',
569
- border: '1px solid rgba(255, 255, 255, 0.12)',
570
- background: 'rgba(30, 41, 59, 0.35)',
571
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
572
- }}>
573
- <table className="chat-table" style={{
574
- width: '100%',
575
- borderCollapse: 'collapse',
576
- fontSize: '0.86rem',
577
- textAlign: 'left',
578
- lineHeight: '1.5',
579
- }}>
580
- {headerRow && (
581
- <thead>
582
- <tr style={{
583
- background: 'rgba(255, 255, 255, 0.04)',
584
- borderBottom: '2px solid rgba(255, 255, 255, 0.15)',
585
- }}>
586
- {headerRow.map((col, idx) => (
587
- <th key={`th-${idx}`} style={{
588
- padding: '12px 16px',
589
- fontWeight: 700,
590
- color: 'var(--accent, #38bdf8)',
591
- fontFamily: 'ui-sans-serif, system-ui, sans-serif',
592
- }}>
593
- {formatInline(col)}
594
- </th>
595
- ))}
596
- </tr>
597
- </thead>
598
- )}
599
- <tbody>
600
- {dataRows.map((row, rIdx) => (
601
- <tr key={`tr-${rIdx}`} style={{
602
- background: rIdx % 2 === 1 ? 'rgba(255, 255, 255, 0.015)' : 'transparent',
603
- borderBottom: rIdx < dataRows.length - 1 ? '1px solid rgba(255, 255, 255, 0.08)' : 'none',
604
- }}>
605
- {row.map((col, cIdx) => (
606
- <td key={`td-${cIdx}`} style={{
607
- padding: '12px 16px',
608
- color: 'var(--text-main, #e2e8f0)',
609
- }}>
610
- {formatInline(col)}
611
- </td>
612
- ))}
613
- </tr>
614
- ))}
615
- </tbody>
616
- </table>
617
- </div>
618
- )
619
- }
620
-
621
- const lines = displayText.split('\n')
622
- const items: any[] = []
623
-
624
- let inCodeBlock = false
625
- let codeBlockLang = ''
626
- let codeBlockLines: string[] = []
627
-
628
- let inTable = false
629
- let tableLines: string[] = []
630
-
631
- const flushTable = (index: number) => {
632
- if (tableLines.length > 0) {
633
- items.push(renderHtmlTable(tableLines, `table-${index}`))
634
- tableLines = []
635
- inTable = false
636
- }
637
- }
638
-
639
- for (let i = 0; i < lines.length; i++) {
640
- const line = lines[i]
641
-
642
- if (line.trim().startsWith('```')) {
643
- if (inTable) {
644
- flushTable(i)
645
- }
646
- if (inCodeBlock) {
647
- const codeText = codeBlockLines.join('\n')
648
- items.push(
649
- <ChatCodeBlock
650
- key={`code-block-${i}`}
651
- code={codeText}
652
- language={codeBlockLang}
653
- />
654
- )
655
- inCodeBlock = false
656
- codeBlockLines = []
657
- } else {
658
- inCodeBlock = true
659
- codeBlockLang = line.trim().slice(3).trim() || 'plaintext'
660
- }
661
- continue
662
- }
663
-
664
- if (inCodeBlock) {
665
- codeBlockLines.push(line)
666
- continue
667
- }
668
-
669
- if (isTableLine(line)) {
670
- inTable = true
671
- tableLines.push(line)
672
- } else {
673
- if (inTable) {
674
- flushTable(i)
675
- }
676
-
677
- const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
678
- if (headerMatch) {
679
- const level = headerMatch[1].length
680
- const content = headerMatch[2]
681
-
682
- const style = {
683
- fontWeight: 'bold',
684
- display: 'block',
685
- marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
686
- marginBottom: '6px',
687
- fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
688
- color: 'var(--text-main)',
689
- }
690
-
691
- items.push(
692
- <span key={`line-${i}`} style={style}>
693
- {formatInline(content)}
694
- </span>
695
- )
696
- } else {
697
- const listMatch = line.match(/^(\s*)([-*+])\s+(.*)$/)
698
- if (listMatch) {
699
- const indent = listMatch[1]
700
- const content = listMatch[3]
701
- items.push(
702
- <Fragment key={`line-${i}`}>
703
- {indent}• {formatInline(content)}
704
- {i < lines.length - 1 && '\n'}
705
- </Fragment>
706
- )
707
- } else {
708
- items.push(
709
- <Fragment key={`line-${i}`}>
710
- {formatInline(line)}
711
- {i < lines.length - 1 && '\n'}
712
- </Fragment>
713
- )
714
- }
715
- }
716
- }
717
- }
718
-
719
- if (inTable) {
720
- flushTable(lines.length)
721
- }
722
-
723
- if (inCodeBlock && codeBlockLines.length > 0) {
724
- const codeText = codeBlockLines.join('\n')
725
- items.push(
726
- <ChatCodeBlock
727
- key={`code-block-end`}
728
- code={codeText}
729
- language={codeBlockLang}
730
- />
731
- )
732
- }
733
-
734
- return items
735
- }
736
-
737
- const THINKING_LABELS = {
738
- live: 'Thinking…',
739
- completed: (count: number) => `Thinking process (${count} steps)`,
740
- step: (index: number) => `Step ${index}`,
741
- emptyHint: 'Model didn\'t send thinking steps this time',
742
- } as const
743
-
744
- function ThinkingBlock({
745
- thoughts,
746
- isLive = false,
747
- blockKey,
748
- expanded,
749
- onExpandedChange,
750
- showEmptyHint = false,
751
- }: {
752
- thoughts: string[]
753
- isLive?: boolean
754
- blockKey: string
755
- expanded?: boolean
756
- onExpandedChange?: (key: string, open: boolean) => void
757
- showEmptyHint?: boolean
758
- }) {
759
- const contentRef = useRef<HTMLDivElement>(null)
760
- const isControlled = expanded !== undefined && Boolean(onExpandedChange)
761
- const [localOpen, setLocalOpen] = useState(isLive)
762
- const isOpen = isControlled ? Boolean(expanded) : localOpen
763
-
764
- const setOpen = (open: boolean) => {
765
- if (isControlled && onExpandedChange) onExpandedChange(blockKey, open)
766
- else setLocalOpen(open)
767
- }
768
-
769
- useEffect(() => {
770
- if (isLive && !isControlled) setLocalOpen(true)
771
- }, [isLive, isControlled])
772
-
773
- useEffect(() => {
774
- if (!isLive || !isOpen || !contentRef.current) return
775
- contentRef.current.scrollTop = contentRef.current.scrollHeight
776
- }, [thoughts, isLive, isOpen])
777
-
778
- if (thoughts.length === 0 && !showEmptyHint) return null
779
-
780
- if (thoughts.length === 0) {
781
- return (
782
- <div className="thinking-block thinking-block-empty-state" data-live={isLive ? 'true' : 'false'}>
783
- <span className="thinking-block-empty">{THINKING_LABELS.emptyHint}</span>
784
- </div>
785
- )
786
- }
787
-
788
- return (
789
- <div className={`thinking-block${isLive ? ' is-live' : ''}`}>
790
- <button
791
- type="button"
792
- className="thinking-block-header"
793
- onClick={() => setOpen(!isOpen)}
794
- aria-expanded={isOpen}
795
- >
796
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
797
- <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
798
- <line x1="12" y1="17" x2="12.01" y2="17" />
799
- <circle cx="12" cy="12" r="10" />
800
- </svg>
801
- <span className="thinking-block-label">
802
- {isLive ? THINKING_LABELS.live : THINKING_LABELS.completed(thoughts.length)}
803
- </span>
804
- {isLive && <span className="thinking-block-live-dot" aria-hidden="true" />}
805
- <span className={`thinking-block-chevron${isOpen ? ' is-open' : ''}`} aria-hidden="true">
806
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
807
- <polyline points="9 18 15 12 9 6" />
808
- </svg>
809
- </span>
810
- </button>
811
- {isOpen && (
812
- <div className="thinking-block-body">
813
- <div className="thinking-block-content" ref={contentRef}>
814
- {thoughts.map((thought, index) => (
815
- <div className="thinking-block-step" key={`${blockKey}-${index}`}>
816
- {thoughts.length > 1 && (
817
- <div className="thinking-block-step-label">{THINKING_LABELS.step(index + 1)}</div>
818
- )}
819
- <div className="thinking-block-step-body">{renderFormattedMessage(thought)}</div>
820
- </div>
821
- ))}
822
- </div>
823
- </div>
824
- )}
825
- </div>
826
- )
827
- }
828
-
829
- function readableAssistantText(text: string) {
830
- if (typeof text !== 'string') return ''
831
- const trimmed = text.trim()
832
- if (!trimmed.startsWith('{')) return text
833
- try {
834
- const value = JSON.parse(trimmed)
835
- if (value?.action === 'finish' && typeof value?.input?.summary === 'string' && value.input.summary.trim()) {
836
- return value.input.summary
837
- }
838
- if (typeof value?.finish?.summary === 'string' && value.finish.summary.trim()) {
839
- return value.finish.summary
840
- }
841
- } catch {
842
- return text
843
- }
844
- return text
845
- }
846
-
847
- function renderSpeakerIcon(isSpeaking: boolean) {
848
- if (isSpeaking) {
849
- return (
850
- <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' }}>
851
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
852
- <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
853
- <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
854
- </svg>
855
- )
856
- }
857
- return (
858
- <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' }}>
859
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
860
- </svg>
861
- )
862
- }
863
-
864
- function cleanSpeechText(text: string) {
865
- return readableAssistantText(text)
866
- .replace(/\*\*([\s\S]*?)\*\*/g, '$1')
867
- .replace(/[*_`#]/g, '')
868
- .trim()
869
- }
870
-
871
- function numericSetting(value: unknown, fallback: number) {
872
- const numeric = Number(value)
873
- return Number.isFinite(numeric) ? numeric : fallback
874
- }
875
-
876
- interface DiffHunk {
877
- oldText: string
878
- newText: string
879
- }
880
-
881
- interface FileChange {
882
- path: string
883
- created: boolean
884
- additions: number
885
- deletions: number
886
- hunks: DiffHunk[]
887
- }
888
-
889
- function parseFileChangesFromProgress(progress: AgentProgress[]): FileChange[] {
890
- const changes = new Map<string, FileChange>()
891
- let activeEdit: { action: string; path: string; created: boolean; additions: number; deletions: number; hunks: DiffHunk[] } | null = null
892
-
893
- for (const event of progress || []) {
894
- if (event.type === 'ToolStart') {
895
- if (event.data.action === 'apply_patch') {
896
- const patch = (event.data.input as any)?.patch
897
- if (patch && typeof patch.path === 'string') {
898
- let additions = 0
899
- let deletions = 0
900
- const hunksList: DiffHunk[] = []
901
- const hunks = patch.hunks
902
- if (Array.isArray(hunks)) {
903
- for (const hunk of hunks) {
904
- const oldText = hunk?.oldText || ''
905
- const newText = hunk?.newText || ''
906
- const oldLines = oldText ? oldText.split('\n').length : 0
907
- const newLines = newText ? newText.split('\n').length : 0
908
- deletions += oldLines
909
- additions += newLines
910
- hunksList.push({ oldText, newText })
911
- }
912
- }
913
- activeEdit = {
914
- action: 'apply_patch',
915
- path: patch.path,
916
- created: false,
917
- additions,
918
- deletions,
919
- hunks: hunksList
920
- }
921
- }
922
- } else if (event.data.action === 'write_file') {
923
- const path = (event.data.input as any)?.path
924
- const fileContent = (event.data.input as any)?.file_content || ''
925
- if (typeof path === 'string') {
926
- const additions = fileContent ? fileContent.split('\n').length : 0
927
- activeEdit = {
928
- action: 'write_file',
929
- path,
930
- created: true,
931
- additions,
932
- deletions: 0,
933
- hunks: [{ oldText: '', newText: fileContent }]
934
- }
935
- }
936
- } else {
937
- activeEdit = null
938
- }
939
- } else if (event.type === 'ToolEnd') {
940
- if (activeEdit && (event.data.action === 'apply_patch' || event.data.action === 'write_file')) {
941
- const isError = typeof event.data.result === 'string' && event.data.result.startsWith('Error:')
942
- if (!isError) {
943
- try {
944
- const applied = JSON.parse(event.data.result)
945
- const appliedPaths = Array.isArray(applied) ? applied.map(item => item?.path).filter(Boolean) : [activeEdit.path]
946
-
947
- for (const path of appliedPaths) {
948
- const existing = changes.get(path)
949
- if (existing) {
950
- existing.additions += activeEdit.additions
951
- existing.deletions += activeEdit.deletions
952
- existing.hunks.push(...activeEdit.hunks)
953
- } else {
954
- changes.set(path, {
955
- path,
956
- created: activeEdit.created,
957
- additions: activeEdit.additions,
958
- deletions: activeEdit.deletions,
959
- hunks: [...activeEdit.hunks]
960
- })
961
- }
962
- }
963
- } catch (e) {
964
- const path = activeEdit.path
965
- const existing = changes.get(path)
966
- if (existing) {
967
- existing.additions += activeEdit.additions
968
- existing.deletions += activeEdit.deletions
969
- existing.hunks.push(...activeEdit.hunks)
970
- } else {
971
- changes.set(path, {
972
- path,
973
- created: activeEdit.created,
974
- additions: activeEdit.additions,
975
- deletions: activeEdit.deletions,
976
- hunks: [...activeEdit.hunks]
977
- })
978
- }
979
- }
980
- }
981
- }
982
- activeEdit = null
983
- }
984
- }
985
-
986
- return Array.from(changes.values())
987
- }
988
76
 
989
77
  export default function ChatPanel({
990
78
  interactions,
@@ -1010,7 +98,6 @@ export default function ChatPanel({
1010
98
  onSelectImage,
1011
99
  onSelectDocument,
1012
100
  onPasteImage,
1013
- onReadClipboardImage,
1014
101
  onSetMessage,
1015
102
  onSendVoiceMessage,
1016
103
  onRemoveImage,
@@ -1024,20 +111,23 @@ export default function ChatPanel({
1024
111
  onToggleMobileSidebar,
1025
112
  settingsConfig,
1026
113
  onSetModel,
114
+ onCancelMessage,
1027
115
  }: ChatPanelProps) {
1028
116
  const agentActivities = activitiesFrom(agentProgress)
1029
117
  const activeFallbackNotice = fallbackNotice(streamedResponse)
118
+ const lastThinkingProgress = [...agentProgress].reverse().find(p => p.type === 'Thinking')
119
+ let activeAgentName: string | null = null
120
+ let activeModelName: string | null = null
121
+ if (lastThinkingProgress && lastThinkingProgress.type === 'Thinking') {
122
+ activeAgentName = (lastThinkingProgress.data as any).agent_name || null
123
+ activeModelName = (lastThinkingProgress.data as any).model_name || null
124
+ }
1030
125
  const [openActivityIds, setOpenActivityIds] = useState<Record<string, boolean>>({})
1031
126
  const [openReviewIds, setOpenReviewIds] = useState<Record<string, boolean>>({})
1032
127
  const [openFileDiffs, setOpenFileDiffs] = useState<Record<string, boolean>>({})
1033
128
  const [toolMenuOpen, setToolMenuOpen] = useState(false)
1034
129
  const toolMenuRef = useRef<HTMLDivElement | null>(null)
1035
130
  const [dynamicOllamaModels, setDynamicOllamaModels] = useState<string[]>(OLLAMA_MODELS)
1036
- const [askAnswer, setAskAnswer] = useState('')
1037
-
1038
- useEffect(() => {
1039
- setAskAnswer('')
1040
- }, [pendingApproval])
1041
131
 
1042
132
  useEffect(() => {
1043
133
  const fetchOllamaModels = async () => {
@@ -1082,8 +172,13 @@ export default function ChatPanel({
1082
172
  case 'ollama':
1083
173
  return dynamicOllamaModels
1084
174
  default:
175
+ if (provider.startsWith('custom:')) {
176
+ const id = provider.replace(/^custom:/, '')
177
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
178
+ return cp?.models.map(m => m.modelId) ?? []
179
+ }
1085
180
  return []
1086
- }
181
+ }
1087
182
  }
1088
183
 
1089
184
  const activeProvider = status?.activeProvider ?? ''
@@ -1109,6 +204,11 @@ export default function ChatPanel({
1109
204
  case 'ollama':
1110
205
  return settingsConfig.ollamaModel
1111
206
  default:
207
+ if (provider.startsWith('custom:')) {
208
+ const id = provider.replace(/^custom:/, '')
209
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
210
+ return (settingsConfig.customModelSelections ?? {})[id] ?? cp?.models[0]?.modelId ?? ''
211
+ }
1112
212
  return ''
1113
213
  }
1114
214
  }
@@ -1133,60 +233,40 @@ export default function ChatPanel({
1133
233
  return () => clearInterval(timer)
1134
234
  }, [sending])
1135
235
 
1136
- const [isRecording, setIsRecording] = useState(false)
1137
- const [voiceMode, setVoiceMode] = useState(false)
1138
- const [voiceTranscript, setVoiceTranscript] = useState('')
1139
- const [voiceAwaitingResponse, setVoiceAwaitingResponse] = useState(false)
1140
236
  const [speakingText, setSpeakingText] = useState<string | null>(null)
1141
- const recognitionRef = useRef<SpeechRecognition | null>(null)
1142
- const mediaRecorderRef = useRef<MediaRecorder | null>(null)
1143
- const mediaStreamRef = useRef<MediaStream | null>(null)
1144
- const audioContextRef = useRef<AudioContext | null>(null)
1145
- const vadTimerRef = useRef<number | null>(null)
1146
237
  const audioRef = useRef<HTMLAudioElement | null>(null)
1147
238
  const speechRunRef = useRef(0)
1148
239
  const historyReadyRef = useRef(false)
1149
240
  const submittedDuringSessionRef = useRef(false)
1150
241
  const lastAutoSpokenIdRef = useRef<number | string | null>(null)
1151
- const voiceModeRef = useRef(false)
1152
- const sendingRef = useRef(false)
1153
- const voiceAwaitingResponseRef = useRef(false)
1154
242
  const speakingRef = useRef<string | null>(null)
1155
- const restartTimerRef = useRef<number | null>(null)
243
+
244
+ const {
245
+ isRecording,
246
+ voiceMode,
247
+ setVoiceMode,
248
+ voiceTranscript,
249
+ setVoiceTranscript,
250
+ voiceAwaitingResponse,
251
+ voiceAwaitingResponseRef,
252
+ voiceModeRef,
253
+ startRecognition,
254
+ stopRecognition,
255
+ scheduleVoiceListen,
256
+ clearRestartTimer
257
+ } = useSpeechToText({
258
+ language: settingsConfig?.language,
259
+ message,
260
+ sending,
261
+ isSpeaking: Boolean(speakingText),
262
+ onSendVoiceMessage,
263
+ onSetMessage: (val) => onSetMessage(val)
264
+ })
265
+
1156
266
  const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
1157
267
  const voiceStatusLabel = voiceStatus === 'speaking' ? 'Speaking' : voiceStatus === 'thinking' ? 'Thinking' : voiceStatus === 'listening' ? 'Listening' : 'Ready'
1158
268
 
1159
- const clearRestartTimer = () => {
1160
- if (restartTimerRef.current === null) return
1161
- window.clearTimeout(restartTimerRef.current)
1162
- restartTimerRef.current = null
1163
- }
1164
- const clearVadTimer = () => {
1165
- if (vadTimerRef.current === null) return
1166
- window.clearInterval(vadTimerRef.current)
1167
- vadTimerRef.current = null
1168
- }
1169
- const stopRecognition = () => {
1170
- clearRestartTimer()
1171
- recognitionRef.current?.stop()
1172
- recognitionRef.current = null
1173
- clearVadTimer()
1174
- if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop()
1175
- mediaRecorderRef.current = null
1176
- mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
1177
- mediaStreamRef.current = null
1178
- audioContextRef.current?.close().catch(() => {})
1179
- audioContextRef.current = null
1180
- setIsRecording(false)
1181
- }
1182
- const scheduleVoiceListen = (delayMs = 350) => {
1183
- clearRestartTimer()
1184
- if (!voiceModeRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
1185
- restartTimerRef.current = window.setTimeout(() => {
1186
- restartTimerRef.current = null
1187
- startRecognition(true)
1188
- }, delayMs)
1189
- }
269
+
1190
270
  const cancelSpeech = () => {
1191
271
  speechRunRef.current += 1
1192
272
  audioRef.current?.pause()
@@ -1208,7 +288,11 @@ export default function ChatPanel({
1208
288
  utterance.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
1209
289
  utterance.rate = Math.max(0.1, Math.min(10, numericSetting(settingsConfig?.ttsSpeed, 1)))
1210
290
  utterance.pitch = Math.max(0, Math.min(2, numericSetting(settingsConfig?.ttsPitch, 1)))
1211
- const voice = window.speechSynthesis.getVoices().find((item) => item.lang.startsWith(hasThai ? 'th' : 'en'))
291
+ const voice = window.speechSynthesis.getVoices().find((item) => {
292
+ const lang = item.lang.toLowerCase()
293
+ const target = hasThai ? 'th' : 'en'
294
+ return lang.startsWith(target) || lang.includes(target)
295
+ })
1212
296
  if (voice) utterance.voice = voice
1213
297
  const finishSpeech = () => {
1214
298
  setSpeakingText((current) => (current === displayText ? null : current))
@@ -1264,213 +348,24 @@ export default function ChatPanel({
1264
348
  }
1265
349
  speakNative(speechText, text)
1266
350
  }
1267
- const blobToDataUri = (blob: Blob) => new Promise<string>((resolve, reject) => {
1268
- const reader = new FileReader()
1269
- reader.onerror = () => reject(reader.error ?? new Error('Failed to read audio recording'))
1270
- reader.onload = () => resolve(String(reader.result))
1271
- reader.readAsDataURL(blob)
1272
- })
1273
- const preferredAudioMimeType = () => {
1274
- const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']
1275
- return candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? ''
1276
- }
1277
- const startAudioRecording = async (autoSend: boolean) => {
1278
- if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
1279
- alert("Sorry, this system cannot access the microphone or record audio in this browser")
1280
- voiceModeRef.current = false
1281
- setVoiceMode(false)
1282
- return
1283
- }
1284
-
1285
- try {
1286
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
1287
- const mimeType = preferredAudioMimeType()
1288
- const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
1289
- const chunks: Blob[] = []
1290
- let heardVoice = false
1291
- let quietSince = 0
1292
- const startedAt = Date.now()
1293
- const audioContext = new AudioContext()
1294
- const source = audioContext.createMediaStreamSource(stream)
1295
- const analyser = audioContext.createAnalyser()
1296
- const samples = new Uint8Array(analyser.fftSize)
1297
- source.connect(analyser)
1298
-
1299
- mediaStreamRef.current = stream
1300
- mediaRecorderRef.current = recorder
1301
- audioContextRef.current = audioContext
1302
- setVoiceTranscript('Listening to microphone...')
1303
-
1304
- recorder.ondataavailable = (event) => {
1305
- if (event.data.size > 0) chunks.push(event.data)
1306
- }
1307
- recorder.onstop = async () => {
1308
- clearVadTimer()
1309
- mediaRecorderRef.current = null
1310
- mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
1311
- mediaStreamRef.current = null
1312
- audioContextRef.current?.close().catch(() => {})
1313
- audioContextRef.current = null
1314
- setIsRecording(false)
1315
- if (!autoSend || !voiceModeRef.current || chunks.length === 0) return
1316
- if (!heardVoice) {
1317
- setVoiceTranscript('No speech detected')
1318
- scheduleVoiceListen(700)
1319
- return
1320
- }
1321
- const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
1322
- const audioDataUri = await blobToDataUri(blob)
1323
- setVoiceTranscript('Sent audio to AI')
1324
- voiceAwaitingResponseRef.current = true
1325
- setVoiceAwaitingResponse(true)
1326
- onSendVoiceMessage('', audioDataUri)
1327
- .catch((error) => console.error('Voice audio message failed', error))
1328
- .finally(() => {
1329
- voiceAwaitingResponseRef.current = false
1330
- setVoiceAwaitingResponse(false)
1331
- scheduleVoiceListen()
1332
- })
1333
- }
1334
-
1335
- recorder.start()
1336
- setIsRecording(true)
1337
- vadTimerRef.current = window.setInterval(() => {
1338
- analyser.getByteTimeDomainData(samples)
1339
- let peak = 0
1340
- for (const sample of samples) {
1341
- peak = Math.max(peak, Math.abs(sample - 128))
1342
- }
1343
- const now = Date.now()
1344
- if (peak > 12) {
1345
- heardVoice = true
1346
- quietSince = 0
1347
- setVoiceTranscript('Listening...')
1348
- } else if (heardVoice) {
1349
- quietSince = quietSince || now
1350
- }
1351
- const silenceElapsed = quietSince ? now - quietSince : 0
1352
- const totalElapsed = now - startedAt
1353
- if ((heardVoice && silenceElapsed > 1300) || totalElapsed > 12000) {
1354
- recorder.stop()
1355
- }
1356
- }, 120)
1357
- } catch (error) {
1358
- console.error('Failed to record microphone audio', error)
1359
- setIsRecording(false)
1360
- voiceModeRef.current = false
1361
- setVoiceMode(false)
1362
- alert('Failed to open microphone. Please check microphone permissions in your browser')
1363
- }
1364
- }
1365
- const startRecognition = (autoSend: boolean) => {
1366
- if (recognitionRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
1367
-
1368
- const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition
1369
- if (!SpeechRecognitionApi) {
1370
- startAudioRecording(autoSend)
1371
- return
1372
- }
1373
351
 
1374
- let sentTranscript = false
1375
-
1376
- try {
1377
- const recognition = new SpeechRecognitionApi()
1378
- recognition.continuous = false
1379
- recognition.interimResults = true
1380
- recognition.lang = settingsConfig?.language === 'en' ? 'en-US' : 'th-TH'
1381
- recognition.onstart = () => setIsRecording(true)
1382
- recognition.onresult = (event: SpeechRecognitionEvent) => {
1383
- let interimText = ''
1384
- let finalText = ''
1385
- for (let index = event.resultIndex; index < event.results.length; index += 1) {
1386
- const transcript = event.results[index]?.[0]?.transcript ?? ''
1387
- if (event.results[index]?.isFinal) finalText += transcript
1388
- else interimText += transcript
1389
- }
1390
- const displayText = (finalText || interimText).trim()
1391
- if (displayText) setVoiceTranscript(displayText)
1392
- const resultText = finalText.trim()
1393
- if (!resultText) return
1394
- sentTranscript = true
1395
- recognition.stop()
1396
- setVoiceTranscript(resultText)
1397
- if (autoSend) {
1398
- voiceAwaitingResponseRef.current = true
1399
- setVoiceAwaitingResponse(true)
1400
- onSendVoiceMessage(resultText)
1401
- .catch((error) => console.error('Voice message failed', error))
1402
- .finally(() => {
1403
- voiceAwaitingResponseRef.current = false
1404
- setVoiceAwaitingResponse(false)
1405
- scheduleVoiceListen()
1406
- })
1407
- } else {
1408
- onSetMessage(message.trim() ? `${message.trimEnd()} ${resultText}` : resultText)
1409
- }
1410
- }
1411
- recognition.onerror = (event: Event) => {
1412
- console.error('Speech recognition error', event)
1413
- setIsRecording(false)
1414
- }
1415
- recognition.onend = () => {
1416
- recognitionRef.current = null
1417
- setIsRecording(false)
1418
- if (autoSend && voiceModeRef.current && !sentTranscript) scheduleVoiceListen()
1419
- }
1420
- recognitionRef.current = recognition
1421
- recognition.start()
1422
- } catch (error) {
1423
- console.error('Failed to start speech recognition', error)
1424
- recognitionRef.current = null
1425
- setIsRecording(false)
1426
- }
1427
- }
1428
352
  const toggleRecording = () => {
1429
353
  if (voiceMode) {
1430
- voiceModeRef.current = false
1431
- voiceAwaitingResponseRef.current = false
1432
354
  setVoiceMode(false)
1433
- setVoiceAwaitingResponse(false)
1434
- setVoiceTranscript('')
1435
355
  stopRecognition()
1436
356
  cancelSpeech()
1437
357
  return
1438
358
  }
1439
359
 
1440
- voiceModeRef.current = true
1441
360
  setVoiceMode(true)
1442
- setVoiceAwaitingResponse(false)
1443
- setVoiceTranscript('')
1444
361
  startRecognition(true)
1445
362
  }
1446
363
 
1447
364
  useEffect(() => {
1448
365
  return () => {
1449
- clearRestartTimer()
1450
366
  cancelSpeech()
1451
- stopRecognition()
1452
367
  }
1453
368
  }, [])
1454
- useEffect(() => {
1455
- voiceModeRef.current = voiceMode
1456
- if (!voiceMode) {
1457
- clearRestartTimer()
1458
- setVoiceTranscript('')
1459
- }
1460
- }, [voiceMode])
1461
- useEffect(() => {
1462
- sendingRef.current = sending
1463
- }, [sending])
1464
- useEffect(() => {
1465
- voiceAwaitingResponseRef.current = voiceAwaitingResponse
1466
- }, [voiceAwaitingResponse])
1467
- useEffect(() => {
1468
- speakingRef.current = speakingText
1469
- }, [speakingText])
1470
- useEffect(() => {
1471
- if (!voiceMode || sending || voiceAwaitingResponse || speakingText || isRecording) return
1472
- scheduleVoiceListen()
1473
- }, [voiceMode, sending, voiceAwaitingResponse, speakingText, isRecording])
1474
369
  useEffect(() => {
1475
370
  if (sending) submittedDuringSessionRef.current = true
1476
371
  }, [sending])
@@ -1485,14 +380,19 @@ export default function ChatPanel({
1485
380
  }
1486
381
  }
1487
382
  if (sending) return
1488
- if (!settingsConfig?.enableVoiceReply && !voiceMode) {
1489
- lastAutoSpokenIdRef.current = latest?.id ?? null
383
+ if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
384
+
385
+ if (!settingsConfig?.enableVoiceReply) {
386
+ lastAutoSpokenIdRef.current = latest.id
387
+ if (voiceMode) {
388
+ scheduleVoiceListen(350)
389
+ }
1490
390
  return
1491
391
  }
1492
- if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
392
+
1493
393
  lastAutoSpokenIdRef.current = latest.id
1494
394
  speak(latest.aiText)
1495
- }, [interactions, sending, settingsConfig?.enableVoiceReply])
395
+ }, [interactions, sending, settingsConfig?.enableVoiceReply, voiceMode])
1496
396
 
1497
397
  // Drag and Drop Zone Overlay
1498
398
  const [isDragging, setIsDragging] = useState(false)
@@ -1576,21 +476,20 @@ export default function ChatPanel({
1576
476
  if (onPasteImage(event.clipboardData)) {
1577
477
  event.preventDefault()
1578
478
  event.stopPropagation()
1579
- } else {
1580
- window.setTimeout(() => void onReadClipboardImage(), 0)
1581
479
  }
1582
480
  }
1583
481
  window.addEventListener('paste', handleWindowPaste, true)
1584
482
  return () => window.removeEventListener('paste', handleWindowPaste, true)
1585
- }, [onPasteImage, onReadClipboardImage])
483
+ }, [onPasteImage])
1586
484
  useEffect(() => {
1587
- const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1588
- if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'v') return
1589
- window.setTimeout(() => void onReadClipboardImage(), 0)
485
+ const handleEscapeKeyDown = (event: globalThis.KeyboardEvent) => {
486
+ if (event.key === 'Escape' && sending) {
487
+ onCancelMessage()
488
+ }
1590
489
  }
1591
- window.addEventListener('keydown', handleWindowKeyDown, true)
1592
- return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
1593
- }, [onReadClipboardImage])
490
+ window.addEventListener('keydown', handleEscapeKeyDown, true)
491
+ return () => window.removeEventListener('keydown', handleEscapeKeyDown, true)
492
+ }, [sending, onCancelMessage])
1594
493
  useEffect(() => {
1595
494
  if (message) return
1596
495
  const input = document.getElementById('chat-input') as HTMLTextAreaElement | null
@@ -1613,24 +512,72 @@ export default function ChatPanel({
1613
512
  const renderCompletedActivity = (interaction: any) => {
1614
513
  const interactionId = String(interaction.id)
1615
514
  const activityView = activitiesFrom(agentActivitySnapshots[interactionId] ?? [])
1616
- if (activityView.items.length === 0) return null
1617
515
  const isOpen = Boolean(openActivityIds[interactionId])
1618
516
  return (
1619
- <div className="agent-activity-history">
1620
- <button
1621
- type="button"
1622
- className="agent-activity-toggle"
1623
- aria-expanded={isOpen}
1624
- onClick={() => setOpenActivityIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
1625
- >
1626
- <span>{activityView.summary}</span>
1627
- <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1628
- </button>
1629
- {isOpen && (
1630
- <div className="agent-activity-card agent-activity-card-history">
1631
- <AgentActivityTable activityView={activityView} />
1632
- </div>
1633
- )}
517
+ <AgentActivityDrawer
518
+ activityView={activityView}
519
+ isOpen={isOpen}
520
+ onToggle={() => setOpenActivityIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
521
+ isHistorical={true}
522
+ />
523
+ )
524
+ }
525
+
526
+ const renderWebSearchSources = (interaction: any) => {
527
+ const interactionId = String(interaction.id)
528
+ const progress = agentActivitySnapshots[interactionId] ?? interaction.agentActivity ?? []
529
+ const sources = parseWebSearchSources(progress)
530
+ if (sources.length === 0) return null
531
+ return (
532
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', marginBottom: '8px' }}>
533
+ <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sources</span>
534
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
535
+ {sources.map((src, i) => (
536
+ <a
537
+ key={i}
538
+ href={src.url}
539
+ target="_blank"
540
+ rel="noopener noreferrer"
541
+ title={src.snippet || src.title}
542
+ style={{
543
+ display: 'flex',
544
+ alignItems: 'center',
545
+ gap: '6px',
546
+ padding: '5px 10px',
547
+ background: 'rgba(255,255,255,0.04)',
548
+ border: '1px solid rgba(255,255,255,0.08)',
549
+ borderRadius: '8px',
550
+ textDecoration: 'none',
551
+ color: '#cbd5e1',
552
+ fontSize: '0.78rem',
553
+ maxWidth: '220px',
554
+ overflow: 'hidden',
555
+ cursor: 'pointer',
556
+ transition: 'background 0.15s, border-color 0.15s',
557
+ }}
558
+ onMouseEnter={e => {
559
+ (e.currentTarget as HTMLAnchorElement).style.background = 'rgba(255,255,255,0.08)'
560
+ ;(e.currentTarget as HTMLAnchorElement).style.borderColor = 'rgba(255,255,255,0.18)'
561
+ }}
562
+ onMouseLeave={e => {
563
+ (e.currentTarget as HTMLAnchorElement).style.background = 'rgba(255,255,255,0.04)'
564
+ ;(e.currentTarget as HTMLAnchorElement).style.borderColor = 'rgba(255,255,255,0.08)'
565
+ }}
566
+ >
567
+ <img
568
+ src={src.faviconUrl}
569
+ alt=""
570
+ width={14}
571
+ height={14}
572
+ style={{ borderRadius: '2px', flexShrink: 0 }}
573
+ onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
574
+ />
575
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
576
+ {src.domain}
577
+ </span>
578
+ </a>
579
+ ))}
580
+ </div>
1634
581
  </div>
1635
582
  )
1636
583
  }
@@ -1927,6 +874,7 @@ export default function ChatPanel({
1927
874
  )
1928
875
  })()}
1929
876
  <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
877
+ {renderWebSearchSources(interaction)}
1930
878
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1931
879
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
1932
880
  {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
@@ -1961,18 +909,13 @@ export default function ChatPanel({
1961
909
  {sending && (
1962
910
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1963
911
  <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
1964
- {agentMode && agentActivities.items.length > 0 && (
1965
- <div className="message ai-message agent-activity-message">
1966
- <div className="agent-activity-card">
1967
- <div className="agent-activity-header">
1968
- <span>{agentActivities.summary}</span>
1969
- <span className="agent-activity-status" data-state={pendingApproval ? 'approval' : 'active'}>
1970
- {pendingApproval ? 'Waiting for approval' : 'Working'}
1971
- </span>
1972
- </div>
1973
- <AgentActivityTable activityView={agentActivities} />
1974
- </div>
1975
- </div>
912
+ {agentMode && (
913
+ <AgentActivityDrawer
914
+ activityView={agentActivities}
915
+ isOpen={openActivityIds['live'] ?? true}
916
+ onToggle={() => setOpenActivityIds((current) => ({ ...current, live: !(current['live'] ?? true) }))}
917
+ pendingApproval={!!pendingApproval}
918
+ />
1976
919
  )}
1977
920
  {renderActiveFileChanges()}
1978
921
  <div className="message ai-message thinking-message">
@@ -2009,7 +952,12 @@ export default function ChatPanel({
2009
952
  />
2010
953
  </path>
2011
954
  </svg>
2012
- <span>Thinking for {elapsedSeconds}s</span>
955
+ <span>
956
+ {activeAgentName && activeModelName
957
+ ? `${activeAgentName} (${activeModelName}) is thinking... (${elapsedSeconds}s)`
958
+ : `Thinking for ${elapsedSeconds}s (Esc to cancel)`
959
+ }
960
+ </span>
2013
961
  </div>
2014
962
  )}
2015
963
  </span>
@@ -2047,85 +995,17 @@ export default function ChatPanel({
2047
995
  </div>
2048
996
  )}
2049
997
 
2050
- {pendingApproval && (() => {
2051
- const details = renderApprovalDetails(pendingApproval.approval)
2052
- const writeFile = pendingApproval.approval?.WriteFile
2053
- const applyPatch = pendingApproval.approval?.ApplyPatch
2054
- const diffText = writeFile?.diff || applyPatch?.diff
2055
- const isAskUser = !!pendingApproval.approval?.AskUser
2056
-
2057
- return (
2058
- <div className="message ai-message" style={{ width: '100%' }}>
2059
- <div className="bubble-wrapper" style={{ width: '100%' }}>
2060
- <div className="action-card approval-card" data-tier={details.isDangerous ? 'dangerous' : undefined} style={{ width: '100%' }}>
2061
- <div className="approval-card-content" style={{ width: '100%' }}>
2062
- <div className="approval-card-title">{details.title}</div>
2063
- <div className="approval-card-body" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.body}</div>
2064
- {diffText ? (
2065
- <div className="approval-card-diff-container" style={{ marginTop: '8px', width: '100%' }}>
2066
- <div style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-soft, #94a3b8)', marginBottom: '4px' }}>Diff:</div>
2067
- {renderDiff(diffText)}
2068
- </div>
2069
- ) : (
2070
- details.reason && <div className="approval-card-reason" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.reason}</div>
2071
- )}
2072
- {isAskUser && (
2073
- <div className="approval-card-input-container" style={{ marginTop: '12px', width: '100%' }}>
2074
- <textarea
2075
- style={{
2076
- width: '100%',
2077
- minHeight: '70px',
2078
- padding: '10px',
2079
- background: 'rgba(0, 0, 0, 0.2)',
2080
- border: '1px solid rgba(255, 255, 255, 0.1)',
2081
- borderRadius: '6px',
2082
- color: 'var(--text-main, #f8fafc)',
2083
- fontSize: '0.9rem',
2084
- fontFamily: 'inherit',
2085
- resize: 'vertical',
2086
- outline: 'none',
2087
- }}
2088
- placeholder="Type your answer here..."
2089
- value={askAnswer}
2090
- onChange={(e) => setAskAnswer(e.target.value)}
2091
- />
2092
- </div>
2093
- )}
2094
- </div>
2095
- {isAskUser ? (
2096
- <div className="approval-card-actions">
2097
- <button
2098
- type="button"
2099
- className="approval-btn approval-btn-approve"
2100
- disabled={!askAnswer.trim()}
2101
- onClick={() => onApproval(true, false, askAnswer)}
2102
- >
2103
- Submit Answer
2104
- </button>
2105
- <button
2106
- type="button"
2107
- className="approval-btn approval-btn-cancel"
2108
- onClick={() => onApproval(false)}
2109
- >
2110
- Decline
2111
- </button>
2112
- </div>
2113
- ) : (
2114
- <div className="approval-card-actions">
2115
- <button type="button" className="approval-btn approval-btn-approve" onClick={() => onApproval(true)}>Approve</button>
2116
- <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>
2117
- <button type="button" className="approval-btn approval-btn-cancel" onClick={() => onApproval(false)}>Cancel</button>
2118
- </div>
2119
- )}
2120
- </div>
2121
- </div>
2122
- </div>
2123
- )
2124
- })()}
998
+ {pendingApproval && (
999
+ <ApprovalCard
1000
+ key={pendingApproval.id || JSON.stringify(pendingApproval)}
1001
+ pendingApproval={pendingApproval}
1002
+ onApproval={onApproval}
1003
+ />
1004
+ )}
2125
1005
  <div ref={chatEnd} />
2126
1006
  </div>
2127
1007
 
2128
- <div className="input-area">
1008
+ <div className={`input-area ${voiceMode ? 'voice-active' : ''}`}>
2129
1009
  {isEmptyChat && <div className="empty-chat-prompt">Mint Agent is ready to work</div>}
2130
1010
  <div className="smart-context-bar">
2131
1011
  <div className="smart-context-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
@@ -2139,8 +1019,21 @@ export default function ChatPanel({
2139
1019
  {voiceMode && (
2140
1020
  <div className="voice-mode-bar" data-state={voiceStatus}>
2141
1021
  <span className="voice-mode-dot" />
2142
- <span>{voiceStatusLabel}</span>
2143
- {voiceTranscript && <span className="voice-mode-transcript">{voiceTranscript}</span>}
1022
+ {voiceTranscript ? (
1023
+ voiceTranscript === 'Listening to microphone...' ||
1024
+ voiceTranscript === 'Listening...' ||
1025
+ voiceTranscript === 'No speech detected' ||
1026
+ voiceTranscript === 'Sent audio to AI' ? (
1027
+ <span>{voiceTranscript}</span>
1028
+ ) : (
1029
+ <span style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0, overflow: 'hidden' }}>
1030
+ <span>{voiceStatusLabel}:</span>
1031
+ <span className="voice-mode-transcript">"{voiceTranscript}"</span>
1032
+ </span>
1033
+ )
1034
+ ) : (
1035
+ <span>{voiceStatusLabel}</span>
1036
+ )}
2144
1037
  </div>
2145
1038
  )}
2146
1039
 
@@ -2259,6 +1152,11 @@ export default function ChatPanel({
2259
1152
  else if (provider === 'huggingface') displayName = 'HF'
2260
1153
  else if (provider === 'local_openai') displayName = 'Local'
2261
1154
  else if (provider === 'ollama') displayName = 'Ollama'
1155
+ else if (provider.startsWith('custom:')) {
1156
+ const id = provider.replace(/^custom:/, '')
1157
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
1158
+ displayName = cp?.displayName || id
1159
+ }
2262
1160
  return <option key={provider} value={provider}>{displayName}</option>
2263
1161
  })}
2264
1162
  </select>
@@ -2312,12 +1210,27 @@ export default function ChatPanel({
2312
1210
  </svg>
2313
1211
  )}
2314
1212
  </button>
2315
- <button id="send-btn" type="submit" disabled={sending || !canSubmit} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
2316
- <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">
2317
- <line x1="22" y1="2" x2="11" y2="13"></line>
2318
- <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
2319
- </svg>
2320
- </button>
1213
+ {sending ? (
1214
+ <button
1215
+ id="send-btn"
1216
+ className="stop-btn"
1217
+ type="button"
1218
+ onClick={onCancelMessage}
1219
+ title="Stop generating"
1220
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
1221
+ >
1222
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
1223
+ <rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
1224
+ </svg>
1225
+ </button>
1226
+ ) : (
1227
+ <button id="send-btn" type="submit" disabled={!canSubmit} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
1228
+ <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">
1229
+ <line x1="22" y1="2" x2="11" y2="13"></line>
1230
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
1231
+ </svg>
1232
+ </button>
1233
+ )}
2321
1234
  </form>
2322
1235
  </div>
2323
1236
  <p className="input-disclaimer">