groove-dev 0.27.196 → 0.27.197

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 (68) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +3 -5
  4. package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -2
  6. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  7. package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
  8. package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
  9. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
  10. package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +1043 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +1 -0
  12. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  13. package/node_modules/@groove-dev/gui/package.json +1 -1
  14. package/node_modules/@groove-dev/gui/src/App.jsx +0 -2
  15. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
  16. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
  17. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -3
  18. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +5 -163
  21. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
  22. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
  23. package/node_modules/@groove-dev/gui/src/views/settings.jsx +0 -61
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/api.js +3 -5
  28. package/packages/daemon/src/chatstore.js +104 -0
  29. package/packages/daemon/src/index.js +3 -2
  30. package/packages/daemon/src/process.js +1 -1
  31. package/packages/daemon/src/routes/agents.js +1 -175
  32. package/packages/daemon/src/routes/chat-history.js +34 -0
  33. package/packages/gui/dist/assets/index-Da2nbd6M.js +1043 -0
  34. package/packages/gui/dist/assets/index-zmrIbwNm.css +1 -0
  35. package/packages/gui/dist/index.html +2 -2
  36. package/packages/gui/package.json +1 -1
  37. package/packages/gui/src/App.jsx +0 -2
  38. package/packages/gui/src/components/agents/agent-config.jsx +2 -2
  39. package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
  40. package/packages/gui/src/components/layout/activity-bar.jsx +2 -3
  41. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  42. package/packages/gui/src/components/layout/command-palette.jsx +1 -1
  43. package/packages/gui/src/stores/groove.js +5 -163
  44. package/packages/gui/src/stores/slices/agents-slice.js +52 -2
  45. package/packages/gui/src/views/marketplace.jsx +1 -1
  46. package/packages/gui/src/views/settings.jsx +0 -61
  47. package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
  48. package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
  49. package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  50. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
  51. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
  52. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
  53. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
  54. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
  55. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
  56. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
  57. package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
  58. package/packages/daemon/src/conversations.js +0 -576
  59. package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
  60. package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  61. package/packages/gui/src/components/chat/chat-header.jsx +0 -198
  62. package/packages/gui/src/components/chat/chat-input.jsx +0 -367
  63. package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
  64. package/packages/gui/src/components/chat/chat-view.jsx +0 -212
  65. package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
  66. package/packages/gui/src/components/chat/model-picker.jsx +0 -201
  67. package/packages/gui/src/stores/slices/chat-slice.js +0 -227
  68. package/packages/gui/src/views/chat.jsx +0 -6
@@ -1,198 +0,0 @@
1
- // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState, useRef, useEffect } from 'react';
3
- import { Pencil, Pin, PinOff, Trash2, Hash, MoreHorizontal, ChevronDown, X } from 'lucide-react';
4
- import { useGrooveStore } from '../../stores/groove';
5
- import { fmtNum } from '../../lib/format';
6
- import { cn } from '../../lib/cn';
7
- import { roleColor } from '../../lib/status';
8
-
9
- const CHAT_ROLES = [
10
- { id: 'chat', label: 'Chat', desc: 'General conversation' },
11
- { id: 'research', label: 'Research Assistant', desc: 'Explore ideas, web search' },
12
- { id: 'fullstack', label: 'Fullstack', desc: 'End-to-end engineering' },
13
- { id: 'backend', label: 'Backend', desc: 'APIs, services, databases' },
14
- { id: 'frontend', label: 'Frontend', desc: 'UI, components, styling' },
15
- { id: 'devops', label: 'DevOps', desc: 'CI/CD, infra, deployment' },
16
- { id: 'security', label: 'Security', desc: 'Audits, vulnerabilities' },
17
- { id: 'database', label: 'Database', desc: 'Schema, migrations, queries' },
18
- { id: 'testing', label: 'Testing', desc: 'Tests, coverage, QA' },
19
- { id: 'docs', label: 'Docs', desc: 'Documentation, guides' },
20
- { id: 'cmo', label: 'CMO', desc: 'Marketing, content, growth' },
21
- { id: 'cfo', label: 'CFO', desc: 'Finance, metrics, forecasting' },
22
- { id: 'ea', label: 'EA', desc: 'Executive assistant' },
23
- { id: 'analyst', label: 'Analyst', desc: 'Data analysis, insights' },
24
- { id: 'creative', label: 'Writer', desc: 'Copy, articles, proposals' },
25
- { id: 'support', label: 'Support', desc: 'Customer support, FAQs' },
26
- ];
27
-
28
- export function ChatHeader({ conversation, role, onRoleChange, sidebarCollapsed }) {
29
- const renameConversation = useGrooveStore((s) => s.renameConversation);
30
- const pinConversation = useGrooveStore((s) => s.pinConversation);
31
- const deleteConversation = useGrooveStore((s) => s.deleteConversation);
32
-
33
- const [editing, setEditing] = useState(false);
34
- const [title, setTitle] = useState(conversation.title || '');
35
- const [menuOpen, setMenuOpen] = useState(false);
36
- const [roleMenuOpen, setRoleMenuOpen] = useState(false);
37
- const inputRef = useRef(null);
38
- const menuRef = useRef(null);
39
- const roleMenuRef = useRef(null);
40
-
41
- useEffect(() => {
42
- setTitle(conversation.title || '');
43
- setEditing(false);
44
- }, [conversation.id, conversation.title]);
45
-
46
- useEffect(() => {
47
- if (editing && inputRef.current) inputRef.current.focus();
48
- }, [editing]);
49
-
50
- useEffect(() => {
51
- if (!menuOpen) return;
52
- function handleClick(e) {
53
- if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
54
- }
55
- document.addEventListener('mousedown', handleClick);
56
- return () => document.removeEventListener('mousedown', handleClick);
57
- }, [menuOpen]);
58
-
59
- useEffect(() => {
60
- if (!roleMenuOpen) return;
61
- function handleClick(e) {
62
- if (roleMenuRef.current && !roleMenuRef.current.contains(e.target)) setRoleMenuOpen(false);
63
- }
64
- document.addEventListener('mousedown', handleClick);
65
- return () => document.removeEventListener('mousedown', handleClick);
66
- }, [roleMenuOpen]);
67
-
68
- function handleRename() {
69
- const trimmed = title.trim();
70
- if (trimmed && trimmed !== conversation.title) {
71
- renameConversation(conversation.id, trimmed);
72
- } else {
73
- setTitle(conversation.title || '');
74
- }
75
- setEditing(false);
76
- }
77
-
78
- const agent = useGrooveStore((s) => s.agents.find((a) => a.id === conversation.agentId));
79
- const tokens = agent?.tokensUsed || 0;
80
-
81
- return (
82
- <div className={cn("h-12 flex items-center gap-3 border-b border-border-subtle bg-surface-0/80 flex-shrink-0", sidebarCollapsed ? 'pl-10 pr-4' : 'px-4')}>
83
- <Hash size={14} className="text-text-4 flex-shrink-0" />
84
-
85
- {editing ? (
86
- <input
87
- ref={inputRef}
88
- value={title}
89
- onChange={(e) => setTitle(e.target.value)}
90
- onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') { setTitle(conversation.title || ''); setEditing(false); } }}
91
- onBlur={handleRename}
92
- className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-text-0 font-sans outline-none border-b border-accent"
93
- maxLength={100}
94
- />
95
- ) : (
96
- <button
97
- onClick={() => setEditing(true)}
98
- className="flex-1 min-w-0 text-left text-sm font-semibold text-text-0 font-sans truncate hover:text-accent transition-colors cursor-pointer"
99
- >
100
- {conversation.title || 'New Chat'}
101
- </button>
102
- )}
103
-
104
- <div className="w-px h-5 bg-border-subtle" />
105
-
106
- <div className="flex items-center gap-2 flex-shrink-0">
107
- <div ref={roleMenuRef} className="relative">
108
- <button
109
- onClick={() => setRoleMenuOpen(!roleMenuOpen)}
110
- className={cn(
111
- 'flex items-center gap-1.5 h-7 px-2.5 rounded-lg text-2xs font-semibold font-sans transition-colors cursor-pointer border',
112
- role
113
- ? 'border-current/20'
114
- : 'bg-surface-3 border-border-subtle text-text-3 hover:text-text-1',
115
- )}
116
- style={role ? { background: roleColor(role).bg, color: roleColor(role).text, borderColor: `${roleColor(role).border}33` } : undefined}
117
- >
118
- {role ? CHAT_ROLES.find((r) => r.id === role)?.label || role : 'Role'}
119
- {role ? (
120
- <X
121
- size={10}
122
- className="opacity-60 hover:opacity-100"
123
- onClick={(e) => { e.stopPropagation(); onRoleChange?.(null); }}
124
- />
125
- ) : (
126
- <ChevronDown size={10} />
127
- )}
128
- </button>
129
- {roleMenuOpen && (
130
- <div className="absolute left-0 top-full mt-1 w-56 rounded-lg border border-border bg-surface-1 shadow-xl z-50 py-1 max-h-72 overflow-y-auto">
131
- {CHAT_ROLES.map((r) => {
132
- const rc = roleColor(r.id);
133
- const active = role === r.id;
134
- return (
135
- <button
136
- key={r.id}
137
- onClick={() => { onRoleChange?.(r.id); setRoleMenuOpen(false); }}
138
- className={cn(
139
- 'w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3 cursor-pointer transition-colors',
140
- active && 'bg-surface-3',
141
- )}
142
- >
143
- <span
144
- className="w-2 h-2 rounded-full flex-shrink-0"
145
- style={{ background: rc.text }}
146
- />
147
- <div className="flex-1 min-w-0">
148
- <div className="text-xs font-semibold font-sans text-text-0">{r.label}</div>
149
- <div className="text-2xs text-text-3 font-sans">{r.desc}</div>
150
- </div>
151
- {active && <span className="text-2xs text-accent font-mono">active</span>}
152
- </button>
153
- );
154
- })}
155
- </div>
156
- )}
157
- </div>
158
-
159
- {tokens > 0 && (
160
- <span className="text-2xs text-text-3 font-mono tabular-nums">{fmtNum(tokens)} tok</span>
161
- )}
162
-
163
- <div ref={menuRef} className="relative">
164
- <button
165
- onClick={() => setMenuOpen(!menuOpen)}
166
- className="w-7 h-7 flex items-center justify-center rounded-md text-text-3 hover:text-text-1 hover:bg-surface-3 transition-colors cursor-pointer"
167
- >
168
- <MoreHorizontal size={14} />
169
- </button>
170
- {menuOpen && (
171
- <div className="absolute right-0 top-full mt-1 w-40 rounded-md border border-border bg-surface-1 shadow-xl z-50 py-1">
172
- <button
173
- onClick={() => { setEditing(true); setMenuOpen(false); }}
174
- className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-text-1 hover:bg-surface-5 cursor-pointer font-sans"
175
- >
176
- <Pencil size={12} /> Rename
177
- </button>
178
- <button
179
- onClick={() => { pinConversation(conversation.id, !conversation.pinned); setMenuOpen(false); }}
180
- className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-text-1 hover:bg-surface-5 cursor-pointer font-sans"
181
- >
182
- {conversation.pinned ? <PinOff size={12} /> : <Pin size={12} />}
183
- {conversation.pinned ? 'Unpin' : 'Pin'}
184
- </button>
185
- <div className="h-px my-1 bg-border-subtle" />
186
- <button
187
- onClick={() => { deleteConversation(conversation.id); setMenuOpen(false); }}
188
- className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-danger hover:bg-danger/10 cursor-pointer font-sans"
189
- >
190
- <Trash2 size={12} /> Delete
191
- </button>
192
- </div>
193
- )}
194
- </div>
195
- </div>
196
- </div>
197
- );
198
- }
@@ -1,367 +0,0 @@
1
- // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState, useRef, useEffect, useCallback } from 'react';
3
- import { SendHorizontal, Loader2, Square, Paperclip, Image as ImageIcon, Zap, Bot, GripHorizontal, ChevronUp } from 'lucide-react';
4
- import { cn } from '../../lib/cn';
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';
8
-
9
- const EFFORT_OPTIONS = [
10
- { value: 'none', label: 'None' },
11
- { value: 'low', label: 'Low' },
12
- { value: 'medium', label: 'Med' },
13
- { value: 'high', label: 'High' },
14
- { value: 'xhigh', label: 'XHigh' },
15
- ];
16
-
17
- const VERBOSITY_OPTIONS = [
18
- { value: 'low', label: 'Concise' },
19
- { value: 'medium', label: 'Normal' },
20
- ];
21
-
22
- export function ChatInput({ onSend, onStop, sending, streaming, disabled, isImageModel, currentModel, currentProvider, onModelChange, replyContext, onClearReply, role, isCodex, reasoningEffort, onReasoningEffortChange, verbosity, onVerbosityChange, mode, onModeChange, modeChanging }) {
23
- const [input, setInput] = useState('');
24
- const [inputHeight, setInputHeight] = useState(88);
25
- const [modelPickerOpen, setModelPickerOpen] = useState(false);
26
- const textareaRef = useRef(null);
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]);
39
-
40
- useEffect(() => {
41
- if (!disabled && textareaRef.current) textareaRef.current.focus();
42
- }, [disabled]);
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
-
53
- function handleSend() {
54
- const text = input.trim();
55
- if (!text || sending || disabled) return;
56
- onSend(text);
57
- setInput('');
58
- }
59
-
60
- function onKeyDown(e) {
61
- if (e.key === 'Enter' && !e.shiftKey) {
62
- e.preventDefault();
63
- handleSend();
64
- }
65
- }
66
-
67
- function handleFileSelect(e) {
68
- const files = Array.from(e.target.files || []);
69
- if (files.length === 0) return;
70
- const pathList = files.map((f) => f.name).join(', ');
71
- setInput((prev) => prev + (prev ? '\n' : '') + `[Attached: ${pathList}]`);
72
- e.target.value = '';
73
- }
74
-
75
- const onDragStart = useCallback((e) => {
76
- e.preventDefault();
77
- const startY = e.clientY;
78
- const startH = inputHeight;
79
- const onMove = (ev) => setInputHeight(Math.min(Math.max(56, startH - (ev.clientY - startY)), 400));
80
- const onUp = () => {
81
- window.removeEventListener('mousemove', onMove);
82
- window.removeEventListener('mouseup', onUp);
83
- };
84
- window.addEventListener('mousemove', onMove);
85
- window.addEventListener('mouseup', onUp);
86
- }, [inputHeight]);
87
-
88
- const isActive = streaming || sending;
89
- const canSend = input.trim() && !sending && !disabled;
90
- const currentMode = mode || 'api';
91
-
92
- const placeholder = disabled
93
- ? 'Select a model to start chatting...'
94
- : isImageModel
95
- ? 'Describe the image you want to generate...'
96
- : role
97
- ? `Ask your ${role}...`
98
- : 'Send a message...';
99
-
100
- return (
101
- <div className="px-4 pb-3">
102
- <div
103
- onMouseDown={onDragStart}
104
- className="flex items-center justify-center h-5 cursor-row-resize group"
105
- >
106
- <GripHorizontal size={12} className="text-text-4 group-hover:text-text-2 transition-colors" />
107
- </div>
108
-
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"
127
- />
128
-
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 }}
146
- />
147
- </div>
148
-
149
- <div className="flex items-center gap-1.5 px-1.5 pb-1.5 pt-0.5">
150
- <button
151
- onClick={() => fileInputRef.current?.click()}
152
- disabled={disabled}
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"
154
- title="Attach file"
155
- >
156
- <Paperclip size={14} />
157
- </button>
158
-
159
- <div className="flex items-center h-6 rounded-md bg-surface-3 border border-border-subtle p-0.5">
160
- <button
161
- onClick={() => onModeChange?.('api')}
162
- disabled={modeChanging}
163
- className={cn(
164
- 'flex items-center gap-1 h-5 px-2 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
165
- 'disabled:opacity-50 disabled:cursor-not-allowed',
166
- currentMode === 'api' ? 'bg-accent/15 text-accent' : 'text-text-3 hover:text-text-1',
167
- )}
168
- title="Lightweight — fast and cheap, no tools"
169
- >
170
- <Zap size={10} /> Chat
171
- </button>
172
- <button
173
- onClick={() => onModeChange?.('agent')}
174
- disabled={modeChanging}
175
- className={cn(
176
- 'flex items-center gap-1 h-5 px-2 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
177
- 'disabled:opacity-50 disabled:cursor-not-allowed',
178
- currentMode === 'agent' ? 'bg-purple/15 text-purple' : 'text-text-3 hover:text-text-1',
179
- )}
180
- title="Full agent — tools, files, session resume"
181
- >
182
- <Bot size={10} /> Agent
183
- </button>
184
- </div>
185
-
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
- >
196
- {isImageModel && <ImageIcon size={9} />}
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>
296
-
297
- {isCodex && (
298
- <>
299
- <div className="flex items-center h-5 rounded bg-surface-3 border border-border-subtle p-0.5">
300
- {EFFORT_OPTIONS.map((opt) => (
301
- <button
302
- key={opt.value}
303
- onClick={() => onReasoningEffortChange?.(opt.value)}
304
- className={cn(
305
- 'h-4 px-1.5 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
306
- reasoningEffort === opt.value
307
- ? 'bg-accent/15 text-accent'
308
- : 'text-text-4 hover:text-text-1',
309
- )}
310
- title={`Reasoning: ${opt.label}`}
311
- >
312
- {opt.label}
313
- </button>
314
- ))}
315
- </div>
316
-
317
- <div className="flex items-center h-5 rounded bg-surface-3 border border-border-subtle p-0.5">
318
- {VERBOSITY_OPTIONS.map((opt) => (
319
- <button
320
- key={opt.value}
321
- onClick={() => onVerbosityChange?.(opt.value)}
322
- className={cn(
323
- 'h-4 px-1.5 rounded text-2xs font-semibold font-sans transition-colors cursor-pointer',
324
- verbosity === opt.value
325
- ? 'bg-accent/15 text-accent'
326
- : 'text-text-4 hover:text-text-1',
327
- )}
328
- title={`Verbosity: ${opt.label}`}
329
- >
330
- {opt.label}
331
- </button>
332
- ))}
333
- </div>
334
- </>
335
- )}
336
-
337
- <div className="flex-1" />
338
-
339
- {isActive ? (
340
- <button
341
- onClick={onStop}
342
- title="Stop generation"
343
- className="group w-7 h-7 flex items-center justify-center rounded-md transition-colors cursor-pointer"
344
- >
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>
349
- </button>
350
- ) : (
351
- <button
352
- onClick={handleSend}
353
- disabled={!canSend}
354
- className={cn(
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',
358
- )}
359
- >
360
- {sending ? <Loader2 size={15} className="animate-spin" /> : <SendHorizontal size={15} />}
361
- </button>
362
- )}
363
- </div>
364
- </div>
365
- </div>
366
- );
367
- }