@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 DragEvent, type FormEvent, type KeyboardEvent, type RefObject } 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[]
@@ -391,7 +58,6 @@ interface ChatPanelProps {
391
58
  onSelectImage: (event: ChangeEvent<HTMLInputElement>) => void
392
59
  onSelectDocument: (event: ChangeEvent<HTMLInputElement>) => void
393
60
  onPasteImage: (clipboardData: DataTransfer) => boolean
394
- onReadClipboardImage: () => Promise<boolean>
395
61
  onSetMessage: (message: string) => void
396
62
  onSendVoiceMessage: (message: string, audioDataUri?: string | null) => Promise<void>
397
63
  onRemoveImage: (idx: number) => void
@@ -405,587 +71,9 @@ interface ChatPanelProps {
405
71
  onApproval: (approved: boolean, autoApproveSession?: boolean, answer?: string) => void
406
72
  settingsConfig: any
407
73
  onSetModel: (model: string) => void
74
+ onCancelMessage: () => void
408
75
  }
409
76
 
410
- function ChatCodeBlock({ code, language }: { code: string; language: string; key?: any }) {
411
- const [copied, setCopied] = useState(false)
412
-
413
- const handleCopy = async () => {
414
- try {
415
- await navigator.clipboard.writeText(code)
416
- setCopied(true)
417
- setTimeout(() => setCopied(false), 2000)
418
- } catch (err) {
419
- console.error('Failed to copy code: ', err)
420
- }
421
- }
422
-
423
- const handleDownload = () => {
424
- try {
425
- const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
426
- const url = URL.createObjectURL(blob)
427
- const link = document.createElement('a')
428
- link.href = url
429
- link.download = `code.${language === 'plaintext' ? 'txt' : language}`
430
- link.click()
431
- URL.revokeObjectURL(url)
432
- } catch (err) {
433
- console.error('Failed to download code: ', err)
434
- }
435
- }
436
-
437
- const displayLang = language.charAt(0).toUpperCase() + language.slice(1).toLowerCase()
438
-
439
- return (
440
- <div className="chat-code-block-container" style={{ whiteSpace: 'normal' }}>
441
- <div className="chat-code-block-header">
442
- <span className="chat-code-block-lang">{displayLang}</span>
443
- <div className="chat-code-block-actions">
444
- <button type="button" onClick={handleDownload} title="Download code" className="chat-code-action-btn">
445
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
446
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
447
- <polyline points="7 10 12 15 17 10" />
448
- <line x1="12" y1="15" x2="12" y2="3" />
449
- </svg>
450
- </button>
451
- <button type="button" onClick={handleCopy} title="Copy code" className="chat-code-action-btn">
452
- {copied ? (
453
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
454
- <polyline points="20 6 9 17 4 12" />
455
- </svg>
456
- ) : (
457
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
458
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
459
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
460
- </svg>
461
- )}
462
- </button>
463
- </div>
464
- </div>
465
- <pre className="chat-code-block-body">
466
- <code>{code}</code>
467
- </pre>
468
- </div>
469
- )
470
- }
471
-
472
- const isTableLine = (line: string): boolean => {
473
- const trimmed = line.trim()
474
- return trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.length > 1
475
- }
476
-
477
- function renderFormattedMessage(text: string) {
478
- const displayText = readableAssistantText(text)
479
- if (!displayText) return null
480
-
481
- const formatInline = (str: string) => {
482
- const codeParts = str.split(/`([\s\S]*?)`/g)
483
- return codeParts.map((codePart, codeIndex) => {
484
- if (codeIndex % 2 === 1) {
485
- return (
486
- <code key={`code-${codeIndex}`} className="chat-inline-code">
487
- {codePart}
488
- </code>
489
- )
490
- }
491
- const boldParts = codePart.split(/\*\*([\s\S]*?)\*\*/g)
492
- return boldParts.map((boldPart, boldIndex) => {
493
- if (boldIndex % 2 === 1) {
494
- return (
495
- <strong key={`bold-${boldIndex}`} className="chat-bold-highlight">
496
- {boldPart}
497
- </strong>
498
- )
499
- }
500
- const mentionParts = boldPart.split(/(@[\w\-\.\/]+)/g)
501
- return mentionParts.map((mentionPart, mentionIndex) => {
502
- if (mentionIndex % 2 === 1) {
503
- return (
504
- <span key={`mention-${mentionIndex}`} className="chat-mention">
505
- {mentionPart}
506
- </span>
507
- )
508
- }
509
- return mentionPart
510
- })
511
- })
512
- })
513
- }
514
-
515
- const renderHtmlTable = (tableLines: string[], key: string) => {
516
- const rows: string[][] = []
517
- for (const line of tableLines) {
518
- const trimmed = line.trim()
519
- if (!trimmed) continue
520
- const content = trimmed.slice(1, -1)
521
- const contentEscaped = content.replace(/\\\|/g, '\u0000')
522
- const cols = contentEscaped.split('|').map(s => s.replace(/\u0000/g, '|').trim())
523
- rows.push(cols)
524
- }
525
-
526
- if (rows.length === 0) return null
527
-
528
- let hasSeparator = false
529
- let separatorIdx = -1
530
- for (let i = 0; i < rows.length; i++) {
531
- const row = rows[i]
532
- if (row.length > 0 && row.every(col => /^[-\s:]+$/.test(col))) {
533
- hasSeparator = true
534
- separatorIdx = i
535
- break
536
- }
537
- }
538
-
539
- let headerRow: string[] | null = null
540
- const dataRows: string[][] = []
541
-
542
- if (hasSeparator) {
543
- if (separatorIdx > 0) {
544
- headerRow = rows[separatorIdx - 1]
545
- for (let i = 0; i < rows.length; i++) {
546
- if (i !== separatorIdx && i !== separatorIdx - 1) {
547
- dataRows.push(rows[i])
548
- }
549
- }
550
- } else {
551
- for (let i = 0; i < rows.length; i++) {
552
- if (i !== separatorIdx) {
553
- dataRows.push(rows[i])
554
- }
555
- }
556
- }
557
- } else {
558
- headerRow = rows[0]
559
- for (let i = 1; i < rows.length; i++) {
560
- dataRows.push(rows[i])
561
- }
562
- }
563
-
564
- return (
565
- <div key={key} className="chat-table-container" style={{
566
- overflowX: 'auto',
567
- margin: '14px 0',
568
- width: '100%',
569
- borderRadius: '8px',
570
- border: '1px solid rgba(255, 255, 255, 0.12)',
571
- background: 'rgba(30, 41, 59, 0.35)',
572
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
573
- }}>
574
- <table className="chat-table" style={{
575
- width: '100%',
576
- borderCollapse: 'collapse',
577
- fontSize: '0.86rem',
578
- textAlign: 'left',
579
- lineHeight: '1.5',
580
- }}>
581
- {headerRow && (
582
- <thead>
583
- <tr style={{
584
- background: 'rgba(255, 255, 255, 0.04)',
585
- borderBottom: '2px solid rgba(255, 255, 255, 0.15)',
586
- }}>
587
- {headerRow.map((col, idx) => (
588
- <th key={`th-${idx}`} style={{
589
- padding: '12px 16px',
590
- fontWeight: 700,
591
- color: 'var(--accent, #38bdf8)',
592
- fontFamily: 'ui-sans-serif, system-ui, sans-serif',
593
- }}>
594
- {formatInline(col)}
595
- </th>
596
- ))}
597
- </tr>
598
- </thead>
599
- )}
600
- <tbody>
601
- {dataRows.map((row, rIdx) => (
602
- <tr key={`tr-${rIdx}`} style={{
603
- background: rIdx % 2 === 1 ? 'rgba(255, 255, 255, 0.015)' : 'transparent',
604
- borderBottom: rIdx < dataRows.length - 1 ? '1px solid rgba(255, 255, 255, 0.08)' : 'none',
605
- }}>
606
- {row.map((col, cIdx) => (
607
- <td key={`td-${cIdx}`} style={{
608
- padding: '12px 16px',
609
- color: 'var(--text-main, #e2e8f0)',
610
- }}>
611
- {formatInline(col)}
612
- </td>
613
- ))}
614
- </tr>
615
- ))}
616
- </tbody>
617
- </table>
618
- </div>
619
- )
620
- }
621
-
622
- const lines = displayText.split('\n')
623
- const items: any[] = []
624
-
625
- let inCodeBlock = false
626
- let codeBlockLang = ''
627
- let codeBlockLines: string[] = []
628
-
629
- let inTable = false
630
- let tableLines: string[] = []
631
-
632
- const flushTable = (index: number) => {
633
- if (tableLines.length > 0) {
634
- items.push(renderHtmlTable(tableLines, `table-${index}`))
635
- tableLines = []
636
- inTable = false
637
- }
638
- }
639
-
640
- for (let i = 0; i < lines.length; i++) {
641
- const line = lines[i]
642
-
643
- if (line.trim().startsWith('```')) {
644
- if (inTable) {
645
- flushTable(i)
646
- }
647
- if (inCodeBlock) {
648
- const codeText = codeBlockLines.join('\n')
649
- items.push(
650
- <ChatCodeBlock
651
- key={`code-block-${i}`}
652
- code={codeText}
653
- language={codeBlockLang}
654
- />
655
- )
656
- inCodeBlock = false
657
- codeBlockLines = []
658
- } else {
659
- inCodeBlock = true
660
- codeBlockLang = line.trim().slice(3).trim() || 'plaintext'
661
- }
662
- continue
663
- }
664
-
665
- if (inCodeBlock) {
666
- codeBlockLines.push(line)
667
- continue
668
- }
669
-
670
- if (isTableLine(line)) {
671
- inTable = true
672
- tableLines.push(line)
673
- } else {
674
- if (inTable) {
675
- flushTable(i)
676
- }
677
-
678
- const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
679
- if (headerMatch) {
680
- const level = headerMatch[1].length
681
- const content = headerMatch[2]
682
-
683
- const style = {
684
- fontWeight: 'bold',
685
- display: 'block',
686
- marginTop: level === 1 ? '16px' : level === 2 ? '14px' : '10px',
687
- marginBottom: '6px',
688
- fontSize: level === 1 ? '1.25em' : level === 2 ? '1.15em' : '1.05em',
689
- color: 'var(--text-main)',
690
- }
691
-
692
- items.push(
693
- <span key={`line-${i}`} style={style}>
694
- {formatInline(content)}
695
- </span>
696
- )
697
- } else {
698
- const listMatch = line.match(/^(\s*)([-*+])\s+(.*)$/)
699
- if (listMatch) {
700
- const indent = listMatch[1]
701
- const content = listMatch[3]
702
- items.push(
703
- <Fragment key={`line-${i}`}>
704
- {indent}• {formatInline(content)}
705
- {i < lines.length - 1 && '\n'}
706
- </Fragment>
707
- )
708
- } else {
709
- items.push(
710
- <Fragment key={`line-${i}`}>
711
- {formatInline(line)}
712
- {i < lines.length - 1 && '\n'}
713
- </Fragment>
714
- )
715
- }
716
- }
717
- }
718
- }
719
-
720
- if (inTable) {
721
- flushTable(lines.length)
722
- }
723
-
724
- if (inCodeBlock && codeBlockLines.length > 0) {
725
- const codeText = codeBlockLines.join('\n')
726
- items.push(
727
- <ChatCodeBlock
728
- key={`code-block-end`}
729
- code={codeText}
730
- language={codeBlockLang}
731
- />
732
- )
733
- }
734
-
735
- return items
736
- }
737
-
738
- const THINKING_LABELS = {
739
- live: 'Thinking…',
740
- completed: (count: number) => `Thinking process (${count} steps)`,
741
- step: (index: number) => `Step ${index}`,
742
- emptyHint: 'Model didn\'t send thinking steps this time',
743
- } as const
744
-
745
- function ThinkingBlock({
746
- thoughts,
747
- isLive = false,
748
- blockKey,
749
- expanded,
750
- onExpandedChange,
751
- showEmptyHint = false,
752
- }: {
753
- thoughts: string[]
754
- isLive?: boolean
755
- blockKey: string
756
- expanded?: boolean
757
- onExpandedChange?: (key: string, open: boolean) => void
758
- showEmptyHint?: boolean
759
- }) {
760
- const contentRef = useRef<HTMLDivElement>(null)
761
- const isControlled = expanded !== undefined && Boolean(onExpandedChange)
762
- const [localOpen, setLocalOpen] = useState(isLive)
763
- const isOpen = isControlled ? Boolean(expanded) : localOpen
764
-
765
- const setOpen = (open: boolean) => {
766
- if (isControlled && onExpandedChange) onExpandedChange(blockKey, open)
767
- else setLocalOpen(open)
768
- }
769
-
770
- useEffect(() => {
771
- if (isLive && !isControlled) setLocalOpen(true)
772
- }, [isLive, isControlled])
773
-
774
- useEffect(() => {
775
- if (!isLive || !isOpen || !contentRef.current) return
776
- contentRef.current.scrollTop = contentRef.current.scrollHeight
777
- }, [thoughts, isLive, isOpen])
778
-
779
- if (thoughts.length === 0 && !showEmptyHint) return null
780
-
781
- if (thoughts.length === 0) {
782
- return (
783
- <div className="thinking-block thinking-block-empty-state" data-live={isLive ? 'true' : 'false'}>
784
- <span className="thinking-block-empty">{THINKING_LABELS.emptyHint}</span>
785
- </div>
786
- )
787
- }
788
-
789
- return (
790
- <div className={`thinking-block${isLive ? ' is-live' : ''}`}>
791
- <button
792
- type="button"
793
- className="thinking-block-header"
794
- onClick={() => setOpen(!isOpen)}
795
- aria-expanded={isOpen}
796
- >
797
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
798
- <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
799
- <line x1="12" y1="17" x2="12.01" y2="17" />
800
- <circle cx="12" cy="12" r="10" />
801
- </svg>
802
- <span className="thinking-block-label">
803
- {isLive ? THINKING_LABELS.live : THINKING_LABELS.completed(thoughts.length)}
804
- </span>
805
- {isLive && <span className="thinking-block-live-dot" aria-hidden="true" />}
806
- <span className={`thinking-block-chevron${isOpen ? ' is-open' : ''}`} aria-hidden="true">
807
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
808
- <polyline points="9 18 15 12 9 6" />
809
- </svg>
810
- </span>
811
- </button>
812
- {isOpen && (
813
- <div className="thinking-block-body">
814
- <div className="thinking-block-content" ref={contentRef}>
815
- {thoughts.map((thought, index) => (
816
- <div className="thinking-block-step" key={`${blockKey}-${index}`}>
817
- {thoughts.length > 1 && (
818
- <div className="thinking-block-step-label">{THINKING_LABELS.step(index + 1)}</div>
819
- )}
820
- <div className="thinking-block-step-body">{renderFormattedMessage(thought)}</div>
821
- </div>
822
- ))}
823
- </div>
824
- </div>
825
- )}
826
- </div>
827
- )
828
- }
829
-
830
- function readableAssistantText(text: string) {
831
- if (typeof text !== 'string') return ''
832
- const trimmed = text.trim()
833
- if (!trimmed.startsWith('{')) return text
834
- try {
835
- const value = JSON.parse(trimmed)
836
- if (value?.action === 'finish' && typeof value?.input?.summary === 'string' && value.input.summary.trim()) {
837
- return value.input.summary
838
- }
839
- if (typeof value?.finish?.summary === 'string' && value.finish.summary.trim()) {
840
- return value.finish.summary
841
- }
842
- } catch {
843
- return text
844
- }
845
- return text
846
- }
847
-
848
- function renderSpeakerIcon(isSpeaking: boolean) {
849
- if (isSpeaking) {
850
- return (
851
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
852
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
853
- <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
854
- <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
855
- </svg>
856
- )
857
- }
858
- return (
859
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
860
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
861
- </svg>
862
- )
863
- }
864
-
865
- function cleanSpeechText(text: string) {
866
- return readableAssistantText(text)
867
- .replace(/\*\*([\s\S]*?)\*\*/g, '$1')
868
- .replace(/[*_`#]/g, '')
869
- .trim()
870
- }
871
-
872
- function numericSetting(value: unknown, fallback: number) {
873
- const numeric = Number(value)
874
- return Number.isFinite(numeric) ? numeric : fallback
875
- }
876
-
877
- interface DiffHunk {
878
- oldText: string
879
- newText: string
880
- }
881
-
882
- interface FileChange {
883
- path: string
884
- created: boolean
885
- additions: number
886
- deletions: number
887
- hunks: DiffHunk[]
888
- }
889
-
890
- function parseFileChangesFromProgress(progress: AgentProgress[]): FileChange[] {
891
- const changes = new Map<string, FileChange>()
892
- let activeEdit: { action: string; path: string; created: boolean; additions: number; deletions: number; hunks: DiffHunk[] } | null = null
893
-
894
- for (const event of progress || []) {
895
- if (event.type === 'ToolStart') {
896
- if (event.data.action === 'apply_patch') {
897
- const patch = (event.data.input as any)?.patch
898
- if (patch && typeof patch.path === 'string') {
899
- let additions = 0
900
- let deletions = 0
901
- const hunksList: DiffHunk[] = []
902
- const hunks = patch.hunks
903
- if (Array.isArray(hunks)) {
904
- for (const hunk of hunks) {
905
- const oldText = hunk?.oldText || ''
906
- const newText = hunk?.newText || ''
907
- const oldLines = oldText ? oldText.split('\n').length : 0
908
- const newLines = newText ? newText.split('\n').length : 0
909
- deletions += oldLines
910
- additions += newLines
911
- hunksList.push({ oldText, newText })
912
- }
913
- }
914
- activeEdit = {
915
- action: 'apply_patch',
916
- path: patch.path,
917
- created: false,
918
- additions,
919
- deletions,
920
- hunks: hunksList
921
- }
922
- }
923
- } else if (event.data.action === 'write_file') {
924
- const path = (event.data.input as any)?.path
925
- const fileContent = (event.data.input as any)?.file_content || ''
926
- if (typeof path === 'string') {
927
- const additions = fileContent ? fileContent.split('\n').length : 0
928
- activeEdit = {
929
- action: 'write_file',
930
- path,
931
- created: true,
932
- additions,
933
- deletions: 0,
934
- hunks: [{ oldText: '', newText: fileContent }]
935
- }
936
- }
937
- } else {
938
- activeEdit = null
939
- }
940
- } else if (event.type === 'ToolEnd') {
941
- if (activeEdit && (event.data.action === 'apply_patch' || event.data.action === 'write_file')) {
942
- const isError = typeof event.data.result === 'string' && event.data.result.startsWith('Error:')
943
- if (!isError) {
944
- try {
945
- const applied = JSON.parse(event.data.result)
946
- const appliedPaths = Array.isArray(applied) ? applied.map(item => item?.path).filter(Boolean) : [activeEdit.path]
947
-
948
- for (const path of appliedPaths) {
949
- const existing = changes.get(path)
950
- if (existing) {
951
- existing.additions += activeEdit.additions
952
- existing.deletions += activeEdit.deletions
953
- existing.hunks.push(...activeEdit.hunks)
954
- } else {
955
- changes.set(path, {
956
- path,
957
- created: activeEdit.created,
958
- additions: activeEdit.additions,
959
- deletions: activeEdit.deletions,
960
- hunks: [...activeEdit.hunks]
961
- })
962
- }
963
- }
964
- } catch (e) {
965
- const path = activeEdit.path
966
- const existing = changes.get(path)
967
- if (existing) {
968
- existing.additions += activeEdit.additions
969
- existing.deletions += activeEdit.deletions
970
- existing.hunks.push(...activeEdit.hunks)
971
- } else {
972
- changes.set(path, {
973
- path,
974
- created: activeEdit.created,
975
- additions: activeEdit.additions,
976
- deletions: activeEdit.deletions,
977
- hunks: [...activeEdit.hunks]
978
- })
979
- }
980
- }
981
- }
982
- }
983
- activeEdit = null
984
- }
985
- }
986
-
987
- return Array.from(changes.values())
988
- }
989
77
 
990
78
  export default function ChatPanel({
991
79
  interactions,
@@ -1012,7 +100,6 @@ export default function ChatPanel({
1012
100
  onSelectImage,
1013
101
  onSelectDocument,
1014
102
  onPasteImage,
1015
- onReadClipboardImage,
1016
103
  onSetMessage,
1017
104
  onSendVoiceMessage,
1018
105
  onRemoveImage,
@@ -1026,20 +113,23 @@ export default function ChatPanel({
1026
113
  onApproval,
1027
114
  settingsConfig,
1028
115
  onSetModel,
116
+ onCancelMessage,
1029
117
  }: ChatPanelProps) {
1030
118
  const agentActivities = activitiesFrom(agentProgress)
1031
119
  const activeFallbackNotice = fallbackNotice(streamedResponse)
120
+ const lastThinkingProgress = [...agentProgress].reverse().find(p => p.type === 'Thinking')
121
+ let activeAgentName: string | null = null
122
+ let activeModelName: string | null = null
123
+ if (lastThinkingProgress && lastThinkingProgress.type === 'Thinking') {
124
+ activeAgentName = (lastThinkingProgress.data as any).agent_name || null
125
+ activeModelName = (lastThinkingProgress.data as any).model_name || null
126
+ }
1032
127
  const [openActivityIds, setOpenActivityIds] = useState<Record<string, boolean>>({})
1033
128
  const [openReviewIds, setOpenReviewIds] = useState<Record<string, boolean>>({})
1034
129
  const [openFileDiffs, setOpenFileDiffs] = useState<Record<string, boolean>>({})
1035
130
  const [toolMenuOpen, setToolMenuOpen] = useState(false)
1036
131
  const [elapsedSeconds, setElapsedSeconds] = useState(0)
1037
132
  const [dynamicOllamaModels, setDynamicOllamaModels] = useState<string[]>(OLLAMA_MODELS)
1038
- const [askAnswer, setAskAnswer] = useState('')
1039
-
1040
- useEffect(() => {
1041
- setAskAnswer('')
1042
- }, [pendingApproval])
1043
133
 
1044
134
  useEffect(() => {
1045
135
  const fetchOllamaModels = async () => {
@@ -1079,27 +169,37 @@ export default function ChatPanel({
1079
169
 
1080
170
  return () => clearInterval(timer)
1081
171
  }, [sending])
1082
- const [isRecording, setIsRecording] = useState(false)
1083
- const [voiceMode, setVoiceMode] = useState(false)
1084
- const [voiceTranscript, setVoiceTranscript] = useState('')
1085
- const [voiceAwaitingResponse, setVoiceAwaitingResponse] = useState(false)
1086
172
  const [speakingText, setSpeakingText] = useState<string | null>(null)
1087
173
  const toolMenuRef = useRef<HTMLDivElement | null>(null)
1088
- const recognitionRef = useRef<SpeechRecognition | null>(null)
1089
- const mediaRecorderRef = useRef<MediaRecorder | null>(null)
1090
- const mediaStreamRef = useRef<MediaStream | null>(null)
1091
- const audioContextRef = useRef<AudioContext | null>(null)
1092
- const vadTimerRef = useRef<number | null>(null)
1093
174
  const audioRef = useRef<HTMLAudioElement | null>(null)
1094
175
  const speechRunRef = useRef(0)
1095
176
  const historyReadyRef = useRef(false)
1096
177
  const submittedDuringSessionRef = useRef(false)
1097
178
  const lastAutoSpokenIdRef = useRef<number | string | null>(null)
1098
- const voiceModeRef = useRef(false)
1099
- const sendingRef = useRef(false)
1100
- const voiceAwaitingResponseRef = useRef(false)
1101
179
  const speakingRef = useRef<string | null>(null)
1102
- const restartTimerRef = useRef<number | null>(null)
180
+
181
+ const {
182
+ isRecording,
183
+ voiceMode,
184
+ setVoiceMode,
185
+ voiceTranscript,
186
+ setVoiceTranscript,
187
+ voiceAwaitingResponse,
188
+ voiceAwaitingResponseRef,
189
+ voiceModeRef,
190
+ startRecognition,
191
+ stopRecognition,
192
+ scheduleVoiceListen,
193
+ clearRestartTimer
194
+ } = useSpeechToText({
195
+ language: settingsConfig?.language,
196
+ message,
197
+ sending,
198
+ isSpeaking: Boolean(speakingText),
199
+ onSendVoiceMessage,
200
+ onSetMessage: (val) => onSetMessage(val)
201
+ })
202
+
1103
203
  const canSubmit = Boolean(message.trim() || imageAttachments.length > 0 || documentName)
1104
204
  const sendingImageMarkers = Array.from({ length: sendingImageCount }, (_, index) => `[Image #${index + 1}]`).join(' ')
1105
205
  const voiceStatus = speakingText ? 'speaking' : (sending || voiceAwaitingResponse) ? 'thinking' : isRecording ? 'listening' : voiceMode ? 'ready' : 'off'
@@ -1124,8 +224,13 @@ export default function ChatPanel({
1124
224
  case 'ollama':
1125
225
  return dynamicOllamaModels
1126
226
  default:
227
+ if (provider.startsWith('custom:')) {
228
+ const id = provider.replace(/^custom:/, '')
229
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
230
+ return cp?.models.map(m => m.modelId) ?? []
231
+ }
1127
232
  return []
1128
- }
233
+ }
1129
234
  }
1130
235
 
1131
236
  const activeProvider = status?.activeProvider ?? ''
@@ -1151,6 +256,11 @@ export default function ChatPanel({
1151
256
  case 'ollama':
1152
257
  return settingsConfig.ollamaModel
1153
258
  default:
259
+ if (provider.startsWith('custom:')) {
260
+ const id = provider.replace(/^custom:/, '')
261
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
262
+ return (settingsConfig.customModelSelections ?? {})[id] ?? cp?.models[0]?.modelId ?? ''
263
+ }
1154
264
  return ''
1155
265
  }
1156
266
  }
@@ -1166,39 +276,7 @@ export default function ChatPanel({
1166
276
  const handleInputKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
1167
277
  submitOnEnter(event)
1168
278
  }
1169
- const clearRestartTimer = () => {
1170
- if (restartTimerRef.current === null) return
1171
- window.clearTimeout(restartTimerRef.current)
1172
- restartTimerRef.current = null
1173
- }
1174
- const clearVadTimer = () => {
1175
- if (vadTimerRef.current === null) return
1176
- window.clearInterval(vadTimerRef.current)
1177
- vadTimerRef.current = null
1178
- }
1179
- const stopRecognition = () => {
1180
- clearRestartTimer()
1181
- recognitionRef.current?.stop()
1182
- recognitionRef.current = null
1183
- clearVadTimer()
1184
- if (mediaRecorderRef.current?.state === 'recording') {
1185
- mediaRecorderRef.current.stop()
1186
- }
1187
- mediaRecorderRef.current = null
1188
- mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
1189
- mediaStreamRef.current = null
1190
- audioContextRef.current?.close().catch(() => {})
1191
- audioContextRef.current = null
1192
- setIsRecording(false)
1193
- }
1194
- const scheduleVoiceListen = (delayMs = 350) => {
1195
- clearRestartTimer()
1196
- if (!voiceModeRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
1197
- restartTimerRef.current = window.setTimeout(() => {
1198
- restartTimerRef.current = null
1199
- startRecognition(true)
1200
- }, delayMs)
1201
- }
279
+
1202
280
  const cancelSpeech = () => {
1203
281
  speechRunRef.current += 1
1204
282
  audioRef.current?.pause()
@@ -1220,7 +298,11 @@ export default function ChatPanel({
1220
298
  utterance.volume = Math.max(0, Math.min(1, numericSetting(settingsConfig?.ttsVolume, 1)))
1221
299
  utterance.rate = Math.max(0.1, Math.min(10, numericSetting(settingsConfig?.ttsSpeed, 1)))
1222
300
  utterance.pitch = Math.max(0, Math.min(2, numericSetting(settingsConfig?.ttsPitch, 1)))
1223
- const voice = window.speechSynthesis.getVoices().find((item) => item.lang.startsWith(hasThai ? 'th' : 'en'))
301
+ const voice = window.speechSynthesis.getVoices().find((item) => {
302
+ const lang = item.lang.toLowerCase()
303
+ const target = hasThai ? 'th' : 'en'
304
+ return lang.startsWith(target) || lang.includes(target)
305
+ })
1224
306
  if (voice) utterance.voice = voice
1225
307
  const finishSpeech = () => {
1226
308
  setSpeakingText((current) => (current === displayText ? null : current))
@@ -1275,183 +357,16 @@ export default function ChatPanel({
1275
357
  }
1276
358
  speakNative(speechText, text)
1277
359
  }
1278
- const blobToDataUri = (blob: Blob) => new Promise<string>((resolve, reject) => {
1279
- const reader = new FileReader()
1280
- reader.onerror = () => reject(reader.error ?? new Error('Failed to read audio recording'))
1281
- reader.onload = () => resolve(String(reader.result))
1282
- reader.readAsDataURL(blob)
1283
- })
1284
- const preferredAudioMimeType = () => {
1285
- const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']
1286
- return candidates.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? ''
1287
- }
1288
- const startAudioRecording = async (autoSend: boolean) => {
1289
- if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
1290
- alert('Sorry, this system cannot access the microphone or record audio in this WebView')
1291
- voiceModeRef.current = false
1292
- setVoiceMode(false)
1293
- return
1294
- }
1295
-
1296
- try {
1297
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
1298
- const mimeType = preferredAudioMimeType()
1299
- const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
1300
- const chunks: Blob[] = []
1301
- let heardVoice = false
1302
- let quietSince = 0
1303
- const startedAt = Date.now()
1304
- const audioContext = new AudioContext()
1305
- const source = audioContext.createMediaStreamSource(stream)
1306
- const analyser = audioContext.createAnalyser()
1307
- const samples = new Uint8Array(analyser.fftSize)
1308
- source.connect(analyser)
1309
-
1310
- mediaStreamRef.current = stream
1311
- mediaRecorderRef.current = recorder
1312
- audioContextRef.current = audioContext
1313
- setVoiceTranscript('Listening to microphone...')
1314
-
1315
- recorder.ondataavailable = (event) => {
1316
- if (event.data.size > 0) chunks.push(event.data)
1317
- }
1318
- recorder.onstop = async () => {
1319
- clearVadTimer()
1320
- mediaRecorderRef.current = null
1321
- mediaStreamRef.current?.getTracks().forEach((track) => track.stop())
1322
- mediaStreamRef.current = null
1323
- audioContextRef.current?.close().catch(() => {})
1324
- audioContextRef.current = null
1325
- setIsRecording(false)
1326
- if (!autoSend || !voiceModeRef.current || chunks.length === 0) return
1327
- if (!heardVoice) {
1328
- setVoiceTranscript('No speech detected')
1329
- scheduleVoiceListen(700)
1330
- return
1331
- }
1332
- const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })
1333
- const audioDataUri = await blobToDataUri(blob)
1334
- setVoiceTranscript('Sent audio to AI')
1335
- voiceAwaitingResponseRef.current = true
1336
- setVoiceAwaitingResponse(true)
1337
- onSendVoiceMessage('', audioDataUri)
1338
- .catch((error) => console.error('Voice audio message failed', error))
1339
- .finally(() => {
1340
- voiceAwaitingResponseRef.current = false
1341
- setVoiceAwaitingResponse(false)
1342
- scheduleVoiceListen()
1343
- })
1344
- }
1345
-
1346
- recorder.start()
1347
- setIsRecording(true)
1348
- vadTimerRef.current = window.setInterval(() => {
1349
- analyser.getByteTimeDomainData(samples)
1350
- let peak = 0
1351
- for (const sample of samples) {
1352
- peak = Math.max(peak, Math.abs(sample - 128))
1353
- }
1354
- const now = Date.now()
1355
- if (peak > 12) {
1356
- heardVoice = true
1357
- quietSince = 0
1358
- setVoiceTranscript('Listening...')
1359
- } else if (heardVoice) {
1360
- quietSince = quietSince || now
1361
- }
1362
- const silenceElapsed = quietSince ? now - quietSince : 0
1363
- const totalElapsed = now - startedAt
1364
- if ((heardVoice && silenceElapsed > 1300) || totalElapsed > 12000) {
1365
- recorder.stop()
1366
- }
1367
- }, 120)
1368
- } catch (error) {
1369
- console.error('Failed to record microphone audio', error)
1370
- setIsRecording(false)
1371
- voiceModeRef.current = false
1372
- setVoiceMode(false)
1373
- alert('Failed to open microphone. Please check microphone permissions for this app')
1374
- }
1375
- }
1376
- const startRecognition = (autoSend: boolean) => {
1377
- if (recognitionRef.current || sendingRef.current || voiceAwaitingResponseRef.current || speakingRef.current) return
1378
-
1379
- const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition
1380
- if (!SpeechRecognitionApi) {
1381
- startAudioRecording(autoSend)
1382
- return
1383
- }
1384
360
 
1385
- let sentTranscript = false
1386
-
1387
- try {
1388
- const recognition = new SpeechRecognitionApi()
1389
- recognition.continuous = false
1390
- recognition.interimResults = true
1391
- recognition.lang = settingsConfig?.language === 'en' ? 'en-US' : 'th-TH'
1392
- recognition.onstart = () => setIsRecording(true)
1393
- recognition.onresult = (event: SpeechRecognitionEvent) => {
1394
- let interimText = ''
1395
- let finalText = ''
1396
- for (let index = event.resultIndex; index < event.results.length; index += 1) {
1397
- const transcript = event.results[index]?.[0]?.transcript ?? ''
1398
- if (event.results[index]?.isFinal) finalText += transcript
1399
- else interimText += transcript
1400
- }
1401
- const displayText = (finalText || interimText).trim()
1402
- if (displayText) setVoiceTranscript(displayText)
1403
- const resultText = finalText.trim()
1404
- if (!resultText) return
1405
- sentTranscript = true
1406
- recognition.stop()
1407
- setVoiceTranscript(resultText)
1408
- if (autoSend) {
1409
- voiceAwaitingResponseRef.current = true
1410
- setVoiceAwaitingResponse(true)
1411
- onSendVoiceMessage(resultText)
1412
- .catch((error) => console.error('Voice message failed', error))
1413
- .finally(() => {
1414
- voiceAwaitingResponseRef.current = false
1415
- setVoiceAwaitingResponse(false)
1416
- scheduleVoiceListen()
1417
- })
1418
- } else {
1419
- onSetMessage(message.trim() ? `${message.trimEnd()} ${resultText}` : resultText)
1420
- }
1421
- }
1422
- recognition.onerror = (event: Event) => {
1423
- console.error('Speech recognition error', event)
1424
- setIsRecording(false)
1425
- }
1426
- recognition.onend = () => {
1427
- recognitionRef.current = null
1428
- setIsRecording(false)
1429
- if (autoSend && voiceModeRef.current && !sentTranscript) scheduleVoiceListen()
1430
- }
1431
- recognitionRef.current = recognition
1432
- recognition.start()
1433
- } catch (error) {
1434
- console.error('Failed to start speech recognition', error)
1435
- recognitionRef.current = null
1436
- setIsRecording(false)
1437
- }
1438
- }
1439
361
  const toggleRecording = () => {
1440
362
  if (voiceMode) {
1441
- voiceModeRef.current = false
1442
- voiceAwaitingResponseRef.current = false
1443
363
  setVoiceMode(false)
1444
- setVoiceAwaitingResponse(false)
1445
- setVoiceTranscript('')
1446
364
  stopRecognition()
1447
365
  cancelSpeech()
1448
366
  return
1449
367
  }
1450
368
 
1451
- voiceModeRef.current = true
1452
369
  setVoiceMode(true)
1453
- setVoiceAwaitingResponse(false)
1454
- setVoiceTranscript('')
1455
370
  startRecognition(true)
1456
371
  }
1457
372
  const resizeInput = (element: HTMLTextAreaElement) => {
@@ -1460,31 +375,9 @@ export default function ChatPanel({
1460
375
  }
1461
376
  useEffect(() => {
1462
377
  return () => {
1463
- clearRestartTimer()
1464
378
  cancelSpeech()
1465
- stopRecognition()
1466
379
  }
1467
380
  }, [])
1468
- useEffect(() => {
1469
- voiceModeRef.current = voiceMode
1470
- if (!voiceMode) {
1471
- clearRestartTimer()
1472
- setVoiceTranscript('')
1473
- }
1474
- }, [voiceMode])
1475
- useEffect(() => {
1476
- sendingRef.current = sending
1477
- }, [sending])
1478
- useEffect(() => {
1479
- voiceAwaitingResponseRef.current = voiceAwaitingResponse
1480
- }, [voiceAwaitingResponse])
1481
- useEffect(() => {
1482
- speakingRef.current = speakingText
1483
- }, [speakingText])
1484
- useEffect(() => {
1485
- if (!voiceMode || sending || voiceAwaitingResponse || speakingText || isRecording) return
1486
- scheduleVoiceListen()
1487
- }, [voiceMode, sending, voiceAwaitingResponse, speakingText, isRecording])
1488
381
  useEffect(() => {
1489
382
  if (sending) submittedDuringSessionRef.current = true
1490
383
  }, [sending])
@@ -1499,14 +392,19 @@ export default function ChatPanel({
1499
392
  }
1500
393
  }
1501
394
  if (sending) return
1502
- if (!settingsConfig?.enableVoiceReply && !voiceMode) {
1503
- lastAutoSpokenIdRef.current = latest?.id ?? null
395
+ if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
396
+
397
+ if (!settingsConfig?.enableVoiceReply) {
398
+ lastAutoSpokenIdRef.current = latest.id
399
+ if (voiceMode) {
400
+ scheduleVoiceListen(350)
401
+ }
1504
402
  return
1505
403
  }
1506
- if (!latest?.aiText || latest.id === lastAutoSpokenIdRef.current) return
404
+
1507
405
  lastAutoSpokenIdRef.current = latest.id
1508
406
  speak(latest.aiText)
1509
- }, [interactions, sending, settingsConfig?.enableVoiceReply])
407
+ }, [interactions, sending, settingsConfig?.enableVoiceReply, voiceMode])
1510
408
  useEffect(() => {
1511
409
  if (!toolMenuOpen) return
1512
410
  const closeMenu = (event: MouseEvent) => {
@@ -1522,21 +420,20 @@ export default function ChatPanel({
1522
420
  if (onPasteImage(event.clipboardData)) {
1523
421
  event.preventDefault()
1524
422
  event.stopPropagation()
1525
- } else {
1526
- window.setTimeout(() => void onReadClipboardImage(), 0)
1527
423
  }
1528
424
  }
1529
425
  window.addEventListener('paste', handleWindowPaste, true)
1530
426
  return () => window.removeEventListener('paste', handleWindowPaste, true)
1531
- }, [onPasteImage, onReadClipboardImage])
427
+ }, [onPasteImage])
1532
428
  useEffect(() => {
1533
- const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1534
- if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'v') return
1535
- window.setTimeout(() => void onReadClipboardImage(), 0)
429
+ const handleEscapeKeyDown = (event: globalThis.KeyboardEvent) => {
430
+ if (event.key === 'Escape' && sending) {
431
+ onCancelMessage()
432
+ }
1536
433
  }
1537
- window.addEventListener('keydown', handleWindowKeyDown, true)
1538
- return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
1539
- }, [onReadClipboardImage])
434
+ window.addEventListener('keydown', handleEscapeKeyDown, true)
435
+ return () => window.removeEventListener('keydown', handleEscapeKeyDown, true)
436
+ }, [sending, onCancelMessage])
1540
437
  useEffect(() => {
1541
438
  if (message) return
1542
439
  const input = document.getElementById('chat-input') as HTMLTextAreaElement | null
@@ -1572,24 +469,72 @@ export default function ChatPanel({
1572
469
  const renderCompletedActivity = (interaction: any) => {
1573
470
  const interactionId = String(interaction.id)
1574
471
  const activityView = activitiesFrom(agentActivitySnapshots[interactionId] ?? [])
1575
- if (activityView.items.length === 0) return null
1576
472
  const isOpen = Boolean(openActivityIds[interactionId])
1577
473
  return (
1578
- <div className="agent-activity-history">
1579
- <button
1580
- type="button"
1581
- className="agent-activity-toggle"
1582
- aria-expanded={isOpen}
1583
- onClick={() => setOpenActivityIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
1584
- >
1585
- <span>{activityView.summary}</span>
1586
- <span aria-hidden="true">{isOpen ? '^' : '>'}</span>
1587
- </button>
1588
- {isOpen && (
1589
- <div className="agent-activity-card agent-activity-card-history">
1590
- <AgentActivityTable activityView={activityView} />
1591
- </div>
1592
- )}
474
+ <AgentActivityDrawer
475
+ activityView={activityView}
476
+ isOpen={isOpen}
477
+ onToggle={() => setOpenActivityIds((current) => ({ ...current, [interactionId]: !current[interactionId] }))}
478
+ isHistorical={true}
479
+ />
480
+ )
481
+ }
482
+
483
+ const renderWebSearchSources = (interaction: any) => {
484
+ const interactionId = String(interaction.id)
485
+ const progress = agentActivitySnapshots[interactionId] ?? interaction.agentActivity ?? []
486
+ const sources = parseWebSearchSources(progress)
487
+ if (sources.length === 0) return null
488
+ return (
489
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', marginBottom: '8px' }}>
490
+ <span style={{ fontSize: '0.72rem', color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sources</span>
491
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
492
+ {sources.map((src, i) => (
493
+ <a
494
+ key={i}
495
+ href={src.url}
496
+ target="_blank"
497
+ rel="noopener noreferrer"
498
+ title={src.snippet || src.title}
499
+ style={{
500
+ display: 'flex',
501
+ alignItems: 'center',
502
+ gap: '6px',
503
+ padding: '5px 10px',
504
+ background: 'rgba(255,255,255,0.04)',
505
+ border: '1px solid rgba(255,255,255,0.08)',
506
+ borderRadius: '8px',
507
+ textDecoration: 'none',
508
+ color: '#cbd5e1',
509
+ fontSize: '0.78rem',
510
+ maxWidth: '220px',
511
+ overflow: 'hidden',
512
+ cursor: 'pointer',
513
+ transition: 'background 0.15s, border-color 0.15s',
514
+ }}
515
+ onMouseEnter={e => {
516
+ (e.currentTarget as HTMLAnchorElement).style.background = 'rgba(255,255,255,0.08)'
517
+ ;(e.currentTarget as HTMLAnchorElement).style.borderColor = 'rgba(255,255,255,0.18)'
518
+ }}
519
+ onMouseLeave={e => {
520
+ (e.currentTarget as HTMLAnchorElement).style.background = 'rgba(255,255,255,0.04)'
521
+ ;(e.currentTarget as HTMLAnchorElement).style.borderColor = 'rgba(255,255,255,0.08)'
522
+ }}
523
+ >
524
+ <img
525
+ src={src.faviconUrl}
526
+ alt=""
527
+ width={14}
528
+ height={14}
529
+ style={{ borderRadius: '2px', flexShrink: 0 }}
530
+ onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
531
+ />
532
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
533
+ {src.domain}
534
+ </span>
535
+ </a>
536
+ ))}
537
+ </div>
1593
538
  </div>
1594
539
  )
1595
540
  }
@@ -1837,6 +782,7 @@ export default function ChatPanel({
1837
782
  )
1838
783
  })()}
1839
784
  <div className="message-bubble" style={{ whiteSpace: 'pre-wrap' }}>{renderFormattedMessage(interaction.aiText)}</div>
785
+ {renderWebSearchSources(interaction)}
1840
786
  <div className="message-time" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1841
787
  <button className="provider-badge">{interaction.provider} • {interaction.model}</button>
1842
788
  {fallbackNotice(interaction) && <span className="provider-fallback-notice">{fallbackNotice(interaction)}</span>}
@@ -1859,18 +805,13 @@ export default function ChatPanel({
1859
805
  {sending && (
1860
806
  <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
1861
807
  <div className="message user-message"><div className="bubble-wrapper"><div className="message-bubble">{sendingImageMarkers ? renderFormattedMessage(`${sendingMessage} ${sendingImageMarkers}`) : renderFormattedMessage(sendingMessage)}</div></div></div>
1862
- {agentMode && agentActivities.items.length > 0 && (
1863
- <div className="message ai-message agent-activity-message">
1864
- <div className="agent-activity-card">
1865
- <div className="agent-activity-header">
1866
- <span>{agentActivities.summary}</span>
1867
- <span className="agent-activity-status" data-state={pendingApproval ? 'approval' : 'active'}>
1868
- {pendingApproval ? 'Waiting for approval' : 'Working'}
1869
- </span>
1870
- </div>
1871
- <AgentActivityTable activityView={agentActivities} />
1872
- </div>
1873
- </div>
808
+ {agentMode && (
809
+ <AgentActivityDrawer
810
+ activityView={agentActivities}
811
+ isOpen={openActivityIds['live'] ?? true}
812
+ onToggle={() => setOpenActivityIds((current) => ({ ...current, live: !(current['live'] ?? true) }))}
813
+ pendingApproval={!!pendingApproval}
814
+ />
1874
815
  )}
1875
816
  {renderActiveFileChanges()}
1876
817
  <div className="message ai-message thinking-message">
@@ -1907,7 +848,12 @@ export default function ChatPanel({
1907
848
  />
1908
849
  </path>
1909
850
  </svg>
1910
- <span>Thinking for {elapsedSeconds}s</span>
851
+ <span>
852
+ {activeAgentName && activeModelName
853
+ ? `${activeAgentName} (${activeModelName}) is thinking... (${elapsedSeconds}s)`
854
+ : `Thinking for ${elapsedSeconds}s (Esc to cancel)`
855
+ }
856
+ </span>
1911
857
  </div>
1912
858
  )}
1913
859
  </span>
@@ -1933,85 +879,17 @@ export default function ChatPanel({
1933
879
  </div>
1934
880
  )}
1935
881
 
1936
- {pendingApproval && (() => {
1937
- const details = renderApprovalDetails(pendingApproval.approval)
1938
- const writeFile = pendingApproval.approval?.WriteFile
1939
- const applyPatch = pendingApproval.approval?.ApplyPatch
1940
- const diffText = writeFile?.diff || applyPatch?.diff
1941
- const isAskUser = !!pendingApproval.approval?.AskUser
1942
-
1943
- return (
1944
- <div className="message ai-message" style={{ width: '100%' }}>
1945
- <div className="bubble-wrapper" style={{ width: '100%' }}>
1946
- <div className="action-card approval-card" data-tier={details.isDangerous ? 'dangerous' : undefined} style={{ width: '100%' }}>
1947
- <div className="approval-card-content" style={{ width: '100%' }}>
1948
- <div className="approval-card-title">{details.title}</div>
1949
- <div className="approval-card-body" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.body}</div>
1950
- {diffText ? (
1951
- <div className="approval-card-diff-container" style={{ marginTop: '8px', width: '100%' }}>
1952
- <div style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-soft, #94a3b8)', marginBottom: '4px' }}>Diff:</div>
1953
- {renderDiff(diffText)}
1954
- </div>
1955
- ) : (
1956
- details.reason && <div className="approval-card-reason" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{details.reason}</div>
1957
- )}
1958
- {isAskUser && (
1959
- <div className="approval-card-input-container" style={{ marginTop: '12px', width: '100%' }}>
1960
- <textarea
1961
- style={{
1962
- width: '100%',
1963
- minHeight: '70px',
1964
- padding: '10px',
1965
- background: 'rgba(0, 0, 0, 0.2)',
1966
- border: '1px solid rgba(255, 255, 255, 0.1)',
1967
- borderRadius: '6px',
1968
- color: 'var(--text-main, #f8fafc)',
1969
- fontSize: '0.9rem',
1970
- fontFamily: 'inherit',
1971
- resize: 'vertical',
1972
- outline: 'none',
1973
- }}
1974
- placeholder="Type your answer here..."
1975
- value={askAnswer}
1976
- onChange={(e) => setAskAnswer(e.target.value)}
1977
- />
1978
- </div>
1979
- )}
1980
- </div>
1981
- {isAskUser ? (
1982
- <div className="approval-card-actions">
1983
- <button
1984
- type="button"
1985
- className="approval-btn approval-btn-approve"
1986
- disabled={!askAnswer.trim()}
1987
- onClick={() => onApproval(true, false, askAnswer)}
1988
- >
1989
- Submit Answer
1990
- </button>
1991
- <button
1992
- type="button"
1993
- className="approval-btn approval-btn-cancel"
1994
- onClick={() => onApproval(false)}
1995
- >
1996
- Decline
1997
- </button>
1998
- </div>
1999
- ) : (
2000
- <div className="approval-card-actions">
2001
- <button type="button" className="approval-btn approval-btn-approve" onClick={() => onApproval(true)}>Approve</button>
2002
- <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>
2003
- <button type="button" className="approval-btn approval-btn-cancel" onClick={() => onApproval(false)}>Cancel</button>
2004
- </div>
2005
- )}
2006
- </div>
2007
- </div>
2008
- </div>
2009
- )
2010
- })()}
882
+ {pendingApproval && (
883
+ <ApprovalCard
884
+ key={pendingApproval.id || JSON.stringify(pendingApproval)}
885
+ pendingApproval={pendingApproval}
886
+ onApproval={onApproval}
887
+ />
888
+ )}
2011
889
  <div ref={chatEnd} />
2012
890
  </div>
2013
891
 
2014
- <div className="input-area">
892
+ <div className={`input-area ${voiceMode ? 'voice-active' : ''}`}>
2015
893
  {isEmptyChat && <div className="empty-chat-prompt">Mint Agent is ready to work</div>}
2016
894
  <button type="button" className="workspace-select-btn" onClick={onSelectWorkspace}>
2017
895
  <span aria-hidden="true" style={{ display: 'inline-flex', alignItems: 'center' }}>
@@ -2041,8 +919,21 @@ export default function ChatPanel({
2041
919
  {voiceMode && (
2042
920
  <div className="voice-mode-bar" data-state={voiceStatus}>
2043
921
  <span className="voice-mode-dot" />
2044
- <span>{voiceStatusLabel}</span>
2045
- {voiceTranscript && <span className="voice-mode-transcript">{voiceTranscript}</span>}
922
+ {voiceTranscript ? (
923
+ voiceTranscript === 'Listening to microphone...' ||
924
+ voiceTranscript === 'Listening...' ||
925
+ voiceTranscript === 'No speech detected' ||
926
+ voiceTranscript === 'Sent audio to AI' ? (
927
+ <span>{voiceTranscript}</span>
928
+ ) : (
929
+ <span style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0, overflow: 'hidden' }}>
930
+ <span>{voiceStatusLabel}:</span>
931
+ <span className="voice-mode-transcript">"{voiceTranscript}"</span>
932
+ </span>
933
+ )
934
+ ) : (
935
+ <span>{voiceStatusLabel}</span>
936
+ )}
2046
937
  </div>
2047
938
  )}
2048
939
 
@@ -2179,6 +1070,11 @@ export default function ChatPanel({
2179
1070
  else if (provider === 'huggingface') displayName = 'HF'
2180
1071
  else if (provider === 'local_openai') displayName = 'Local'
2181
1072
  else if (provider === 'ollama') displayName = 'Ollama'
1073
+ else if (provider.startsWith('custom:')) {
1074
+ const id = provider.replace(/^custom:/, '')
1075
+ const cp = (settingsConfig?.customProviders ?? []).find(p => p.id === id)
1076
+ displayName = cp?.displayName || id
1077
+ }
2182
1078
  return <option key={provider} value={provider}>{displayName}</option>
2183
1079
  })}
2184
1080
  </select>
@@ -2232,12 +1128,27 @@ export default function ChatPanel({
2232
1128
  </svg>
2233
1129
  )}
2234
1130
  </button>
2235
- <button id="send-btn" type="submit" disabled={sending || !canSubmit} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
2236
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
2237
- <line x1="22" y1="2" x2="11" y2="13"></line>
2238
- <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
2239
- </svg>
2240
- </button>
1131
+ {sending ? (
1132
+ <button
1133
+ id="send-btn"
1134
+ className="stop-btn"
1135
+ type="button"
1136
+ onClick={onCancelMessage}
1137
+ title="Stop generating"
1138
+ style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
1139
+ >
1140
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
1141
+ <rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
1142
+ </svg>
1143
+ </button>
1144
+ ) : (
1145
+ <button id="send-btn" type="submit" disabled={!canSubmit} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
1146
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1147
+ <line x1="22" y1="2" x2="11" y2="13"></line>
1148
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
1149
+ </svg>
1150
+ </button>
1151
+ )}
2241
1152
  </form>
2242
1153
  </div>
2243
1154
  <p className="input-disclaimer">