groove-dev 0.27.144 → 0.27.146

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 (135) hide show
  1. package/CLAUDE.md +7 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +12 -6
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +59 -58
  6. package/node_modules/@groove-dev/daemon/src/introducer.js +20 -0
  7. package/node_modules/@groove-dev/daemon/src/process.js +262 -15
  8. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +1 -3
  9. package/node_modules/@groove-dev/daemon/src/rotator.js +15 -3
  10. package/node_modules/@groove-dev/daemon/src/routes/agents.js +49 -83
  11. package/node_modules/@groove-dev/daemon/templates/lab-general.json +12 -0
  12. package/node_modules/@groove-dev/daemon/templates/llama-cpp-setup.json +12 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-BKbsE_hn.js +1011 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/index-CEkPsSAm.css +1 -0
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/package.json +1 -1
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +80 -95
  18. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +2 -70
  19. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +132 -4
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +3 -8
  21. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +199 -75
  22. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +21 -4
  23. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +10 -13
  24. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +3 -3
  25. package/node_modules/@groove-dev/gui/src/components/lab/chat-playground.jsx +42 -34
  26. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +9 -3
  27. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +13 -3
  28. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +66 -65
  29. package/node_modules/@groove-dev/gui/src/components/lab/preset-manager.jsx +17 -14
  30. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +124 -127
  31. package/node_modules/@groove-dev/gui/src/components/lab/system-prompt-editor.jsx +10 -8
  32. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +2 -0
  33. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +24 -1
  34. package/node_modules/@groove-dev/gui/src/components/ui/question-modal.jsx +107 -0
  35. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +2 -2
  36. package/node_modules/@groove-dev/gui/src/components/ui/slider.jsx +8 -8
  37. package/node_modules/@groove-dev/gui/src/lib/status.js +1 -0
  38. package/node_modules/@groove-dev/gui/src/stores/groove.js +49 -2
  39. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +18 -2
  40. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +14 -14
  41. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +68 -32
  42. package/node_modules/@groove-dev/gui/src/views/models.jsx +57 -36
  43. package/node_modules/axios/CHANGELOG.md +260 -0
  44. package/node_modules/axios/README.md +595 -223
  45. package/node_modules/axios/dist/axios.js +1460 -1090
  46. package/node_modules/axios/dist/axios.js.map +1 -1
  47. package/node_modules/axios/dist/axios.min.js +3 -3
  48. package/node_modules/axios/dist/axios.min.js.map +1 -1
  49. package/node_modules/axios/dist/browser/axios.cjs +1560 -1132
  50. package/node_modules/axios/dist/browser/axios.cjs.map +1 -1
  51. package/node_modules/axios/dist/esm/axios.js +1557 -1128
  52. package/node_modules/axios/dist/esm/axios.js.map +1 -1
  53. package/node_modules/axios/dist/esm/axios.min.js +2 -2
  54. package/node_modules/axios/dist/esm/axios.min.js.map +1 -1
  55. package/node_modules/axios/dist/node/axios.cjs +1594 -1057
  56. package/node_modules/axios/dist/node/axios.cjs.map +1 -1
  57. package/node_modules/axios/index.d.cts +40 -41
  58. package/node_modules/axios/index.d.ts +151 -227
  59. package/node_modules/axios/index.js +2 -0
  60. package/node_modules/axios/lib/adapters/adapters.js +4 -2
  61. package/node_modules/axios/lib/adapters/fetch.js +147 -16
  62. package/node_modules/axios/lib/adapters/http.js +306 -58
  63. package/node_modules/axios/lib/adapters/xhr.js +6 -2
  64. package/node_modules/axios/lib/core/Axios.js +7 -3
  65. package/node_modules/axios/lib/core/AxiosError.js +120 -34
  66. package/node_modules/axios/lib/core/AxiosHeaders.js +27 -25
  67. package/node_modules/axios/lib/core/buildFullPath.js +1 -1
  68. package/node_modules/axios/lib/core/dispatchRequest.js +19 -7
  69. package/node_modules/axios/lib/core/mergeConfig.js +21 -4
  70. package/node_modules/axios/lib/core/settle.js +7 -11
  71. package/node_modules/axios/lib/defaults/index.js +14 -9
  72. package/node_modules/axios/lib/env/data.js +1 -1
  73. package/node_modules/axios/lib/helpers/AxiosURLSearchParams.js +1 -2
  74. package/node_modules/axios/lib/helpers/buildURL.js +1 -1
  75. package/node_modules/axios/lib/helpers/cookies.js +14 -2
  76. package/node_modules/axios/lib/helpers/estimateDataURLDecodedBytes.js +28 -1
  77. package/node_modules/axios/lib/helpers/formDataToJSON.js +3 -1
  78. package/node_modules/axios/lib/helpers/formDataToStream.js +3 -2
  79. package/node_modules/axios/lib/helpers/parseProtocol.js +1 -1
  80. package/node_modules/axios/lib/helpers/progressEventReducer.js +5 -5
  81. package/node_modules/axios/lib/helpers/resolveConfig.js +54 -18
  82. package/node_modules/axios/lib/helpers/shouldBypassProxy.js +74 -2
  83. package/node_modules/axios/lib/helpers/toFormData.js +10 -2
  84. package/node_modules/axios/lib/helpers/validator.js +3 -1
  85. package/node_modules/axios/lib/utils.js +33 -21
  86. package/node_modules/axios/package.json +17 -24
  87. package/node_modules/follow-redirects/README.md +7 -5
  88. package/node_modules/follow-redirects/index.js +24 -1
  89. package/node_modules/follow-redirects/package.json +1 -1
  90. package/package.json +1 -1
  91. package/packages/cli/package.json +1 -1
  92. package/packages/daemon/package.json +1 -1
  93. package/packages/daemon/src/api.js +12 -6
  94. package/packages/daemon/src/conversations.js +59 -58
  95. package/packages/daemon/src/introducer.js +20 -0
  96. package/packages/daemon/src/process.js +262 -15
  97. package/packages/daemon/src/providers/groove-network.js +1 -3
  98. package/packages/daemon/src/rotator.js +15 -3
  99. package/packages/daemon/src/routes/agents.js +49 -83
  100. package/packages/daemon/templates/lab-general.json +12 -0
  101. package/packages/daemon/templates/llama-cpp-setup.json +12 -0
  102. package/packages/gui/dist/assets/index-BKbsE_hn.js +1011 -0
  103. package/packages/gui/dist/assets/index-CEkPsSAm.css +1 -0
  104. package/packages/gui/dist/index.html +2 -2
  105. package/packages/gui/package.json +1 -1
  106. package/packages/gui/src/components/agents/agent-feed.jsx +80 -95
  107. package/packages/gui/src/components/agents/agent-panel.jsx +2 -70
  108. package/packages/gui/src/components/agents/spawn-wizard.jsx +132 -4
  109. package/packages/gui/src/components/chat/chat-header.jsx +3 -8
  110. package/packages/gui/src/components/chat/chat-input.jsx +199 -75
  111. package/packages/gui/src/components/chat/chat-messages.jsx +21 -4
  112. package/packages/gui/src/components/chat/chat-view.jsx +10 -13
  113. package/packages/gui/src/components/chat/model-picker.jsx +3 -3
  114. package/packages/gui/src/components/lab/chat-playground.jsx +42 -34
  115. package/packages/gui/src/components/lab/lab-assistant.jsx +9 -3
  116. package/packages/gui/src/components/lab/metrics-panel.jsx +13 -3
  117. package/packages/gui/src/components/lab/parameter-panel.jsx +66 -65
  118. package/packages/gui/src/components/lab/preset-manager.jsx +17 -14
  119. package/packages/gui/src/components/lab/runtime-config.jsx +124 -127
  120. package/packages/gui/src/components/lab/system-prompt-editor.jsx +10 -8
  121. package/packages/gui/src/components/layout/app-shell.jsx +2 -0
  122. package/packages/gui/src/components/layout/status-bar.jsx +24 -1
  123. package/packages/gui/src/components/ui/question-modal.jsx +107 -0
  124. package/packages/gui/src/components/ui/sheet.jsx +2 -2
  125. package/packages/gui/src/components/ui/slider.jsx +8 -8
  126. package/packages/gui/src/lib/status.js +1 -0
  127. package/packages/gui/src/stores/groove.js +49 -2
  128. package/packages/gui/src/stores/slices/agents-slice.js +18 -2
  129. package/packages/gui/src/stores/slices/chat-slice.js +14 -14
  130. package/packages/gui/src/views/model-lab.jsx +68 -32
  131. package/packages/gui/src/views/models.jsx +57 -36
  132. package/node_modules/@groove-dev/gui/dist/assets/index-BcoF6_eF.js +0 -1012
  133. package/node_modules/@groove-dev/gui/dist/assets/index-Dd7qhiEd.css +0 -1
  134. package/packages/gui/dist/assets/index-BcoF6_eF.js +0 -1012
  135. package/packages/gui/dist/assets/index-Dd7qhiEd.css +0 -1
@@ -1,8 +1,10 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useRef, useEffect, useCallback } from 'react';
3
- import { Send, Loader2, Square, Paperclip, Image as ImageIcon, Zap, Bot, GripHorizontal } from 'lucide-react';
3
+ import { SendHorizontal, Loader2, Square, Paperclip, Image as ImageIcon, Zap, Bot, GripHorizontal, ChevronUp } from 'lucide-react';
4
4
  import { cn } from '../../lib/cn';
5
- import { formatModelName } from './model-picker';
5
+ import { formatModelName, isImageModel as checkImageModel, getTier, getContextSize, TIER_CONFIG } from './model-picker';
6
+ import { useGrooveStore } from '../../stores/groove';
7
+ import { Badge } from '../ui/badge';
6
8
 
7
9
  const EFFORT_OPTIONS = [
8
10
  { value: 'none', label: 'None' },
@@ -17,22 +19,42 @@ const VERBOSITY_OPTIONS = [
17
19
  { value: 'medium', label: 'Normal' },
18
20
  ];
19
21
 
20
- export function ChatInput({ onSend, onStop, sending, streaming, disabled, isImageModel, currentModel, replyContext, onClearReply, role, isCodex, reasoningEffort, onReasoningEffortChange, verbosity, onVerbosityChange, mode, onModeChange, modeChanging }) {
22
+ export function ChatInput({ onSend, onStop, sending, streaming, disabled, isImageModel, currentModel, currentProvider, onModelChange, replyContext, onClearReply, role, isCodex, reasoningEffort, onReasoningEffortChange, verbosity, onVerbosityChange, mode, onModeChange, modeChanging }) {
21
23
  const [input, setInput] = useState('');
22
- const [inputHeight, setInputHeight] = useState(40);
24
+ const [inputHeight, setInputHeight] = useState(88);
25
+ const [modelPickerOpen, setModelPickerOpen] = useState(false);
23
26
  const textareaRef = useRef(null);
24
27
  const fileInputRef = useRef(null);
28
+ const modelPickerRef = useRef(null);
29
+
30
+ const [providers, setProviders] = useState([]);
31
+ const fetchProviders = useGrooveStore((s) => s.fetchProviders);
32
+
33
+ useEffect(() => {
34
+ fetchProviders().then((data) => {
35
+ if (Array.isArray(data)) setProviders(data);
36
+ else if (data?.providers) setProviders(data.providers);
37
+ }).catch(() => {});
38
+ }, [fetchProviders]);
25
39
 
26
40
  useEffect(() => {
27
41
  if (!disabled && textareaRef.current) textareaRef.current.focus();
28
42
  }, [disabled]);
29
43
 
44
+ useEffect(() => {
45
+ if (!modelPickerOpen) return;
46
+ function handleClick(e) {
47
+ if (modelPickerRef.current && !modelPickerRef.current.contains(e.target)) setModelPickerOpen(false);
48
+ }
49
+ document.addEventListener('mousedown', handleClick);
50
+ return () => document.removeEventListener('mousedown', handleClick);
51
+ }, [modelPickerOpen]);
52
+
30
53
  function handleSend() {
31
54
  const text = input.trim();
32
55
  if (!text || sending || disabled) return;
33
56
  onSend(text);
34
57
  setInput('');
35
- setInputHeight(40);
36
58
  }
37
59
 
38
60
  function onKeyDown(e) {
@@ -54,7 +76,7 @@ export function ChatInput({ onSend, onStop, sending, streaming, disabled, isImag
54
76
  e.preventDefault();
55
77
  const startY = e.clientY;
56
78
  const startH = inputHeight;
57
- const onMove = (ev) => setInputHeight(Math.min(Math.max(40, startH - (ev.clientY - startY)), 400));
79
+ const onMove = (ev) => setInputHeight(Math.min(Math.max(56, startH - (ev.clientY - startY)), 400));
58
80
  const onUp = () => {
59
81
  window.removeEventListener('mousemove', onMove);
60
82
  window.removeEventListener('mouseup', onUp);
@@ -76,110 +98,211 @@ export function ChatInput({ onSend, onStop, sending, streaming, disabled, isImag
76
98
  : 'Send a message...';
77
99
 
78
100
  return (
79
- <div className="border-t border-border-subtle bg-surface-1">
101
+ <div className="px-4 pb-3">
80
102
  <div
81
103
  onMouseDown={onDragStart}
82
- className="flex items-center justify-center h-3 cursor-row-resize hover:bg-surface-3/50 transition-colors group"
104
+ className="flex items-center justify-center h-5 cursor-row-resize group"
83
105
  >
84
- <GripHorizontal size={10} className="text-text-4 group-hover:text-text-2 transition-colors" />
106
+ <GripHorizontal size={12} className="text-text-4 group-hover:text-text-2 transition-colors" />
85
107
  </div>
86
108
 
87
- <div className="px-4 pb-3">
88
- {replyContext && (
89
- <div className="flex items-center gap-2 mb-2 px-3 py-2 rounded-lg bg-accent/5 border border-accent/15">
90
- <ImageIcon size={12} className="text-accent flex-shrink-0" />
91
- <span className="flex-1 text-2xs text-text-2 font-sans truncate">Iterating: &quot;{replyContext.prompt}&quot;</span>
92
- <button onClick={onClearReply} className="text-text-4 hover:text-text-1 cursor-pointer flex-shrink-0">
93
- <Square size={10} />
94
- </button>
95
- </div>
96
- )}
97
-
98
- <textarea
99
- ref={textareaRef}
100
- value={input}
101
- onChange={(e) => setInput(e.target.value)}
102
- onKeyDown={onKeyDown}
103
- placeholder={placeholder}
104
- disabled={disabled}
105
- rows={1}
106
- style={{ height: inputHeight }}
107
- className={cn(
108
- 'w-full resize-none rounded-xl px-4 py-2.5 text-sm',
109
- 'bg-surface-0 border text-text-0 font-sans',
110
- 'placeholder:text-text-4',
111
- 'focus:outline-none focus:ring-1',
112
- 'border-border focus:ring-accent/40',
113
- 'disabled:opacity-50 disabled:cursor-not-allowed',
114
- )}
109
+ {replyContext && (
110
+ <div className="flex items-center gap-2 mb-2 px-3 py-2 rounded-lg bg-accent/5 border border-accent/15">
111
+ <ImageIcon size={12} className="text-accent flex-shrink-0" />
112
+ <span className="flex-1 text-2xs text-text-2 font-sans truncate">Iterating: &quot;{replyContext.prompt}&quot;</span>
113
+ <button onClick={onClearReply} className="text-text-4 hover:text-text-1 cursor-pointer flex-shrink-0">
114
+ <Square size={10} />
115
+ </button>
116
+ </div>
117
+ )}
118
+
119
+ <div className="flex flex-col rounded-lg border border-border-subtle bg-surface-0 transition-colors overflow-hidden focus-within:border-text-4/40">
120
+ <input
121
+ ref={fileInputRef}
122
+ type="file"
123
+ multiple
124
+ accept=".pdf,.png,.jpg,.jpeg,.gif,.svg,.csv,.txt,.md,.json,.yaml,.yml,.docx,.pptx,.xlsx"
125
+ onChange={handleFileSelect}
126
+ className="hidden"
115
127
  />
116
128
 
117
- <div className="flex items-center gap-2 mt-2">
118
- <input
119
- ref={fileInputRef}
120
- type="file"
121
- multiple
122
- accept=".pdf,.png,.jpg,.jpeg,.gif,.svg,.csv,.txt,.md,.json,.yaml,.yml,.docx,.pptx,.xlsx"
123
- onChange={handleFileSelect}
124
- className="hidden"
129
+ <div className="px-1">
130
+ <textarea
131
+ ref={textareaRef}
132
+ value={input}
133
+ onChange={(e) => setInput(e.target.value)}
134
+ onKeyDown={onKeyDown}
135
+ placeholder={placeholder}
136
+ disabled={disabled}
137
+ rows={1}
138
+ className={cn(
139
+ 'w-full resize-none px-3 py-2.5 text-[13px]',
140
+ 'bg-transparent font-sans text-text-0',
141
+ 'placeholder:text-text-4',
142
+ 'focus:outline-none',
143
+ 'disabled:opacity-40 disabled:cursor-not-allowed',
144
+ )}
145
+ style={{ height: inputHeight }}
125
146
  />
147
+ </div>
148
+
149
+ <div className="flex items-center gap-1.5 px-1.5 pb-1.5 pt-0.5">
126
150
  <button
127
151
  onClick={() => fileInputRef.current?.click()}
128
152
  disabled={disabled}
129
- className="w-8 h-8 flex items-center justify-center rounded-lg text-text-4 hover:text-text-1 hover:bg-surface-3 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
153
+ className="w-7 h-7 flex items-center justify-center rounded-md text-text-4 hover:text-text-1 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
130
154
  title="Attach file"
131
155
  >
132
156
  <Paperclip size={14} />
133
157
  </button>
134
158
 
135
- <div className="flex items-center h-7 rounded-lg bg-surface-3 border border-border-subtle p-0.5">
159
+ <div className="flex items-center h-6 rounded-md bg-surface-3 border border-border-subtle p-0.5">
136
160
  <button
137
161
  onClick={() => onModeChange?.('api')}
138
162
  disabled={modeChanging}
139
163
  className={cn(
140
- 'flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer',
164
+ 'flex items-center gap-1 h-5 px-2 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
141
165
  'disabled:opacity-50 disabled:cursor-not-allowed',
142
- currentMode === 'api' ? 'bg-accent/15 text-accent border border-accent/25' : 'text-text-3 hover:text-text-1',
166
+ currentMode === 'api' ? 'bg-accent/15 text-accent' : 'text-text-3 hover:text-text-1',
143
167
  )}
144
168
  title="Lightweight — fast and cheap, no tools"
145
169
  >
146
- <Zap size={11} /> Chat
170
+ <Zap size={10} /> Chat
147
171
  </button>
148
172
  <button
149
173
  onClick={() => onModeChange?.('agent')}
150
174
  disabled={modeChanging}
151
175
  className={cn(
152
- 'flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-semibold font-sans transition-colors cursor-pointer',
176
+ 'flex items-center gap-1 h-5 px-2 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
153
177
  'disabled:opacity-50 disabled:cursor-not-allowed',
154
- currentMode === 'agent' ? 'bg-purple/15 text-purple border border-purple/25' : 'text-text-3 hover:text-text-1',
178
+ currentMode === 'agent' ? 'bg-purple/15 text-purple' : 'text-text-3 hover:text-text-1',
155
179
  )}
156
180
  title="Full agent — tools, files, session resume"
157
181
  >
158
- <Bot size={11} /> Agent
182
+ <Bot size={10} /> Agent
159
183
  </button>
160
184
  </div>
161
185
 
162
- {currentModel && (
163
- <div className={cn(
164
- 'flex items-center gap-1 h-6 px-2 rounded-md text-2xs font-mono border',
165
- isImageModel
166
- ? 'bg-purple/8 border-purple/20 text-purple'
167
- : 'bg-surface-3 border-border-subtle text-text-3',
168
- )}>
186
+ <div ref={modelPickerRef} className="relative">
187
+ <button
188
+ onClick={() => setModelPickerOpen(!modelPickerOpen)}
189
+ className={cn(
190
+ 'flex items-center gap-1 h-5 px-2 rounded text-2xs font-mono transition-colors cursor-pointer',
191
+ isImageModel
192
+ ? 'bg-purple/8 text-purple hover:bg-purple/15'
193
+ : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
194
+ )}
195
+ >
169
196
  {isImageModel && <ImageIcon size={9} />}
170
- <span className="max-w-[80px] truncate">{formatModelName(currentModel)}</span>
171
- </div>
172
- )}
197
+ <span className="max-w-[120px] truncate">{currentModel ? formatModelName(currentModel) : 'Select model'}</span>
198
+ <ChevronUp size={10} className="text-text-4 flex-shrink-0" />
199
+ </button>
200
+
201
+ {modelPickerOpen && (() => {
202
+ const chatProviders = [];
203
+ const imageProviders = [];
204
+ for (const provider of providers) {
205
+ const models = provider.models || [];
206
+ const chat = [];
207
+ const img = [];
208
+ for (const m of models) {
209
+ const mid = typeof m === 'string' ? m : m.id || m.name;
210
+ const mtype = typeof m === 'object' ? m.type : undefined;
211
+ if (mtype === 'image' || checkImageModel(mid)) img.push(m);
212
+ else chat.push(m);
213
+ }
214
+ if (chat.length) chatProviders.push({ ...provider, models: chat });
215
+ if (img.length) imageProviders.push({ ...provider, models: img });
216
+ }
217
+
218
+ const renderModel = (provider, model) => {
219
+ const modelId = typeof model === 'string' ? model : model.id || model.name;
220
+ const modelName = typeof model === 'string' ? model : model.name || model.id;
221
+ const mtype = typeof model === 'object' ? model.type : undefined;
222
+ const isImg = mtype === 'image' || checkImageModel(modelId);
223
+ const tier = isImg ? null : getTier(modelId);
224
+ const tierCfg = tier ? TIER_CONFIG[tier] : null;
225
+ const TierIcon = tierCfg?.icon;
226
+ const isActive = currentModel === modelId && currentProvider === provider.id;
227
+ return (
228
+ <button
229
+ key={modelId}
230
+ onClick={() => {
231
+ onModelChange?.({ provider: provider.id, model: modelId });
232
+ setModelPickerOpen(false);
233
+ }}
234
+ className={cn(
235
+ 'w-full flex items-center gap-3 px-3.5 py-2.5 text-left transition-colors cursor-pointer',
236
+ isActive ? 'bg-accent/10' : 'hover:bg-surface-3',
237
+ )}
238
+ >
239
+ <div className="flex-1 min-w-0">
240
+ <div className="flex items-center gap-1.5">
241
+ {isImg && <ImageIcon size={11} className="text-purple flex-shrink-0" />}
242
+ <span className={cn('text-xs font-medium font-sans truncate', isActive ? 'text-accent' : 'text-text-0')}>{modelName}</span>
243
+ </div>
244
+ {!isImg && (
245
+ <div className="text-2xs text-text-4 font-sans mt-0.5">{getContextSize(modelId)} context</div>
246
+ )}
247
+ </div>
248
+ <div className="flex items-center gap-1.5 flex-shrink-0">
249
+ {isImg ? (
250
+ <Badge variant="purple" className="text-[9px]">
251
+ <ImageIcon size={8} /> Image
252
+ </Badge>
253
+ ) : tierCfg && (
254
+ <Badge variant={tierCfg.variant} className="text-[9px]">
255
+ <TierIcon size={8} /> {tierCfg.label}
256
+ </Badge>
257
+ )}
258
+ </div>
259
+ </button>
260
+ );
261
+ };
262
+
263
+ return (
264
+ <div className="absolute bottom-full left-0 mb-1.5 w-80 max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-surface-1 shadow-2xl z-50">
265
+ {providers.length === 0 && (
266
+ <div className="px-4 py-8 text-center text-xs text-text-3 font-sans">No providers available</div>
267
+ )}
268
+ {chatProviders.map((provider) => (
269
+ <div key={provider.id}>
270
+ <div className="px-3.5 py-2 text-2xs font-semibold text-text-3 uppercase tracking-wider font-sans bg-surface-2/80 border-b border-border-subtle sticky top-0 backdrop-blur-sm">
271
+ {provider.name || provider.id}
272
+ </div>
273
+ {provider.models.map((m) => renderModel(provider, m))}
274
+ </div>
275
+ ))}
276
+ {imageProviders.length > 0 && (
277
+ <>
278
+ <div className="px-3.5 py-2 text-2xs font-semibold text-text-4 uppercase tracking-wider font-sans bg-surface-0 border-y border-border-subtle flex items-center gap-1.5 sticky top-0 backdrop-blur-sm">
279
+ <ImageIcon size={10} className="text-purple" />
280
+ Image Generation
281
+ </div>
282
+ {imageProviders.map((provider) => (
283
+ <div key={`img-${provider.id}`}>
284
+ <div className="px-3.5 py-2 text-2xs font-semibold text-text-3 uppercase tracking-wider font-sans bg-surface-2/80 border-b border-border-subtle">
285
+ {provider.name || provider.id}
286
+ </div>
287
+ {provider.models.map((m) => renderModel(provider, m))}
288
+ </div>
289
+ ))}
290
+ </>
291
+ )}
292
+ </div>
293
+ );
294
+ })()}
295
+ </div>
173
296
 
174
297
  {isCodex && (
175
298
  <>
176
- <div className="flex items-center h-6 rounded-md bg-surface-3 border border-border-subtle p-0.5">
299
+ <div className="flex items-center h-5 rounded bg-surface-3 border border-border-subtle p-0.5">
177
300
  {EFFORT_OPTIONS.map((opt) => (
178
301
  <button
179
302
  key={opt.value}
180
303
  onClick={() => onReasoningEffortChange?.(opt.value)}
181
304
  className={cn(
182
- 'h-5 px-1.5 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
305
+ 'h-4 px-1.5 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
183
306
  reasoningEffort === opt.value
184
307
  ? 'bg-accent/15 text-accent'
185
308
  : 'text-text-4 hover:text-text-1',
@@ -191,13 +314,13 @@ export function ChatInput({ onSend, onStop, sending, streaming, disabled, isImag
191
314
  ))}
192
315
  </div>
193
316
 
194
- <div className="flex items-center h-6 rounded-md bg-surface-3 border border-border-subtle p-0.5">
317
+ <div className="flex items-center h-5 rounded bg-surface-3 border border-border-subtle p-0.5">
195
318
  {VERBOSITY_OPTIONS.map((opt) => (
196
319
  <button
197
320
  key={opt.value}
198
321
  onClick={() => onVerbosityChange?.(opt.value)}
199
322
  className={cn(
200
- 'h-5 px-1.5 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
323
+ 'h-4 px-1.5 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
201
324
  verbosity === opt.value
202
325
  ? 'bg-accent/15 text-accent'
203
326
  : 'text-text-4 hover:text-text-1',
@@ -216,24 +339,25 @@ export function ChatInput({ onSend, onStop, sending, streaming, disabled, isImag
216
339
  {isActive ? (
217
340
  <button
218
341
  onClick={onStop}
219
- className="w-8 h-8 flex items-center justify-center rounded-lg bg-danger/80 text-white hover:bg-danger transition-all cursor-pointer shadow-lg shadow-danger/20 flex-shrink-0"
220
342
  title="Stop generation"
343
+ className="group w-7 h-7 flex items-center justify-center rounded-md transition-colors cursor-pointer"
221
344
  >
222
- <Square size={14} fill="currentColor" />
345
+ <span className="relative flex items-center justify-center w-3.5 h-3.5">
346
+ <span className="absolute inset-0 rounded-full bg-accent/30 group-hover:bg-red-500/30 animate-ping [animation-duration:2s] transition-colors" />
347
+ <span className="relative w-2.5 h-2.5 rounded-full bg-accent group-hover:bg-red-500 transition-colors" />
348
+ </span>
223
349
  </button>
224
350
  ) : (
225
351
  <button
226
352
  onClick={handleSend}
227
353
  disabled={!canSend}
228
354
  className={cn(
229
- 'w-8 h-8 flex items-center justify-center rounded-lg transition-all cursor-pointer flex-shrink-0',
230
- 'disabled:opacity-20 disabled:cursor-not-allowed',
231
- canSend
232
- ? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
233
- : 'bg-surface-4 text-text-4',
355
+ 'w-7 h-7 flex items-center justify-center rounded-md transition-colors cursor-pointer',
356
+ 'disabled:opacity-15 disabled:cursor-not-allowed',
357
+ canSend ? 'text-text-0 hover:text-text-1' : 'text-text-4',
234
358
  )}
235
359
  >
236
- {sending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
360
+ {sending ? <Loader2 size={15} className="animate-spin" /> : <SendHorizontal size={15} />}
237
361
  </button>
238
362
  )}
239
363
  </div>
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useRef, useEffect, useState, useCallback } from 'react';
3
- import { Copy, Check, ArrowRight, Download, Maximize2, X, Image as ImageIcon, RefreshCw } from 'lucide-react';
3
+ import { Copy, Check, ArrowRight, Download, Maximize2, X, Image as ImageIcon, RefreshCw, Loader2 } from 'lucide-react';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { timeAgo } from '../../lib/format';
6
6
  import { ThinkingIndicator } from '../ui/thinking-indicator';
@@ -435,7 +435,23 @@ function ApiTypingIndicator() {
435
435
  );
436
436
  }
437
437
 
438
- export function ChatMessages({ messages, isStreaming, model, mode, onImageReply, role }) {
438
+ function ToolCallIndicator({ tool }) {
439
+ return (
440
+ <div className="max-w-[85%] py-1">
441
+ <div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg bg-surface-3/50 border border-border-subtle/30">
442
+ <Loader2 size={13} className="text-accent animate-spin flex-shrink-0" />
443
+ <div className="flex-1 min-w-0">
444
+ <span className="text-xs font-semibold font-sans text-text-2">{tool.name}</span>
445
+ {tool.summary && (
446
+ <span className="text-xs font-mono text-text-4 ml-1.5 truncate">{tool.summary}</span>
447
+ )}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ );
452
+ }
453
+
454
+ export function ChatMessages({ messages, isStreaming, model, mode, onImageReply, role, activeTool }) {
439
455
  const scrollRef = useRef(null);
440
456
  const isAtBottomRef = useRef(true);
441
457
 
@@ -453,7 +469,7 @@ export function ChatMessages({ messages, isStreaming, model, mode, onImageReply,
453
469
  if (isAtBottomRef.current && scrollRef.current) {
454
470
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
455
471
  }
456
- }, [messages?.length, isStreaming]);
472
+ }, [messages?.length, isStreaming, activeTool]);
457
473
 
458
474
  if (!messages || messages.length === 0) {
459
475
  return (
@@ -472,7 +488,8 @@ export function ChatMessages({ messages, isStreaming, model, mode, onImageReply,
472
488
  if (msg.from === 'system') return <SystemMessage key={i} msg={msg} />;
473
489
  return <AssistantMessage key={i} msg={msg} model={model} role={role} />;
474
490
  })}
475
- {isStreaming && (
491
+ {activeTool && <ToolCallIndicator tool={activeTool} />}
492
+ {isStreaming && !activeTool && (
476
493
  mode === 'agent' ? (
477
494
  <ThinkingIndicator className="py-1" />
478
495
  ) : (
@@ -33,11 +33,12 @@ export function ChatView() {
33
33
  const conversationMessages = useGrooveStore((s) => s.conversationMessages);
34
34
  const sendingMessage = useGrooveStore((s) => s.sendingMessage);
35
35
  const streamingConversationId = useGrooveStore((s) => s.streamingConversationId);
36
+ const conversationActiveTools = useGrooveStore((s) => s.conversationActiveTools);
36
37
  const createConversation = useGrooveStore((s) => s.createConversation);
37
38
  const setActiveConversation = useGrooveStore((s) => s.setActiveConversation);
38
39
  const sendChatMessage = useGrooveStore((s) => s.sendChatMessage);
39
40
  const sendImageMessage = useGrooveStore((s) => s.sendImageMessage);
40
- const stopAgent = useGrooveStore((s) => s.stopAgent);
41
+
41
42
  const stopChatStreaming = useGrooveStore((s) => s.stopChatStreaming);
42
43
  const setConversationMode = useGrooveStore((s) => s.setConversationMode);
43
44
  const setConversationModel = useGrooveStore((s) => s.setConversationModel);
@@ -54,7 +55,7 @@ export function ChatView() {
54
55
  const [replyContext, setReplyContext] = useState(null);
55
56
  const [modeChanging, setModeChanging] = useState(false);
56
57
 
57
- const activeRole = activeConversationId ? (conversationRoles?.[activeConversationId] || null) : null;
58
+ const activeRole = activeConversationId ? (conversationRoles?.[activeConversationId] || 'chat') : 'chat';
58
59
  const activeReasoningEffort = activeConversationId ? (conversationReasoningEffort?.[activeConversationId] || 'medium') : 'medium';
59
60
  const activeVerbosity = activeConversationId ? (conversationVerbosity?.[activeConversationId] || 'medium') : 'medium';
60
61
 
@@ -62,6 +63,7 @@ export function ChatView() {
62
63
  const isCodexProvider = activeConversation?.provider === 'codex';
63
64
  const messages = activeConversationId ? (conversationMessages[activeConversationId] || []) : [];
64
65
  const isStreaming = streamingConversationId === activeConversationId && sendingMessage;
66
+ const activeTool = activeConversationId ? (conversationActiveTools?.[activeConversationId] || null) : null;
65
67
  const currentModelIsImage = activeConversation ? isImageModel(activeConversation.model) : false;
66
68
 
67
69
  const handleNewChat = useCallback(async (provider, model) => {
@@ -101,12 +103,8 @@ export function ChatView() {
101
103
 
102
104
  const handleStop = useCallback(() => {
103
105
  if (!activeConversation) return;
104
- if (activeConversation.mode === 'agent' && activeConversation.agentId) {
105
- stopAgent(activeConversation.agentId);
106
- } else {
107
- stopChatStreaming(activeConversationId);
108
- }
109
- }, [activeConversation, activeConversationId, stopAgent, stopChatStreaming]);
106
+ stopChatStreaming(activeConversationId);
107
+ }, [activeConversation, activeConversationId, stopChatStreaming]);
110
108
 
111
109
  const handleModelChange = useCallback(async (selection) => {
112
110
  if (activeConversationId) {
@@ -130,10 +128,6 @@ export function ChatView() {
130
128
  setConversationVerbosity(activeConversationId, verbosity);
131
129
  }, [activeConversationId, setConversationVerbosity]);
132
130
 
133
- const currentModel = activeConversation
134
- ? { provider: activeConversation.provider, model: activeConversation.model }
135
- : null;
136
-
137
131
  return (
138
132
  <div className="flex h-full bg-surface-0">
139
133
  {/* Conversation sidebar */}
@@ -158,7 +152,7 @@ export function ChatView() {
158
152
 
159
153
  {activeConversation ? (
160
154
  <>
161
- <ChatHeader conversation={activeConversation} model={currentModel} onModelChange={handleModelChange} role={activeRole} onRoleChange={handleRoleChange} sidebarCollapsed={sidebarCollapsed} />
155
+ <ChatHeader conversation={activeConversation} role={activeRole} onRoleChange={handleRoleChange} sidebarCollapsed={sidebarCollapsed} />
162
156
  <ChatMessages
163
157
  messages={messages}
164
158
  isStreaming={isStreaming}
@@ -166,6 +160,7 @@ export function ChatView() {
166
160
  mode={activeConversation.mode || 'api'}
167
161
  onImageReply={handleImageReply}
168
162
  role={activeRole}
163
+ activeTool={activeTool}
169
164
  />
170
165
  <ChatInput
171
166
  onSend={handleSend}
@@ -175,6 +170,8 @@ export function ChatView() {
175
170
  disabled={false}
176
171
  isImageModel={currentModelIsImage}
177
172
  currentModel={activeConversation.model}
173
+ currentProvider={activeConversation.provider}
174
+ onModelChange={handleModelChange}
178
175
  replyContext={replyContext}
179
176
  onClearReply={() => setReplyContext(null)}
180
177
  role={activeRole}
@@ -15,13 +15,13 @@ export function formatModelName(id) {
15
15
  .join(' ');
16
16
  }
17
17
 
18
- const TIER_CONFIG = {
18
+ export const TIER_CONFIG = {
19
19
  frontier: { label: 'Frontier', variant: 'purple', icon: Sparkles },
20
20
  mid: { label: 'Mid', variant: 'accent', icon: Zap },
21
21
  fast: { label: 'Fast', variant: 'success', icon: Zap },
22
22
  };
23
23
 
24
- function getTier(model) {
24
+ export function getTier(model) {
25
25
  const name = (model || '').toLowerCase();
26
26
  if (name.includes('gpt-5.5') || name.includes('gpt-5.4-pro')) return 'frontier';
27
27
  if (name.includes('gpt-5.4-mini') || name.includes('gpt-5-mini')) return 'mid';
@@ -32,7 +32,7 @@ function getTier(model) {
32
32
  return 'fast';
33
33
  }
34
34
 
35
- function getContextSize(model) {
35
+ export function getContextSize(model) {
36
36
  const name = (model || '').toLowerCase();
37
37
  if (name.startsWith('gpt-5')) return '200k';
38
38
  if (name.includes('opus') || name.includes('sonnet')) return '200k';