groove-dev 0.27.74 → 0.27.77

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 (70) 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 +256 -4
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +16 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +41 -1
  6. package/node_modules/@groove-dev/daemon/src/preview.js +32 -2
  7. package/node_modules/@groove-dev/daemon/src/process.js +9 -1
  8. package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/codex.js +38 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/grok.js +156 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/index.js +5 -1
  12. package/node_modules/@groove-dev/daemon/src/providers/nano-banana.js +103 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-BbmPDhuW.js +8616 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.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/app.css +41 -0
  18. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  19. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +16 -5
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +49 -11
  21. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +144 -24
  22. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -2
  23. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +105 -52
  24. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +5 -2
  25. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +215 -88
  26. package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +109 -0
  27. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +278 -0
  28. package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +237 -0
  29. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +6 -2
  30. package/node_modules/@groove-dev/gui/src/stores/groove.js +149 -9
  31. package/node_modules/@groove-dev/gui/src/views/preview.jsx +6 -0
  32. package/node_modules/@groove-dev/gui/src/views/settings.jsx +199 -114
  33. package/package.json +1 -1
  34. package/packages/cli/package.json +1 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +256 -4
  37. package/packages/daemon/src/conversations.js +16 -0
  38. package/packages/daemon/src/index.js +41 -1
  39. package/packages/daemon/src/preview.js +32 -2
  40. package/packages/daemon/src/process.js +9 -1
  41. package/packages/daemon/src/providers/base.js +4 -0
  42. package/packages/daemon/src/providers/codex.js +38 -0
  43. package/packages/daemon/src/providers/grok.js +156 -0
  44. package/packages/daemon/src/providers/index.js +5 -1
  45. package/packages/daemon/src/providers/nano-banana.js +103 -0
  46. package/packages/gui/dist/assets/index-BbmPDhuW.js +8616 -0
  47. package/packages/gui/dist/assets/index-kbR5tOHu.css +1 -0
  48. package/packages/gui/dist/index.html +2 -2
  49. package/packages/gui/package.json +1 -1
  50. package/packages/gui/src/app.css +41 -0
  51. package/packages/gui/src/app.jsx +2 -0
  52. package/packages/gui/src/components/chat/chat-header.jsx +16 -5
  53. package/packages/gui/src/components/chat/chat-input.jsx +49 -11
  54. package/packages/gui/src/components/chat/chat-messages.jsx +144 -24
  55. package/packages/gui/src/components/chat/chat-view.jsx +26 -2
  56. package/packages/gui/src/components/chat/model-picker.jsx +105 -52
  57. package/packages/gui/src/components/layout/activity-bar.jsx +5 -2
  58. package/packages/gui/src/components/layout/welcome-splash.jsx +215 -88
  59. package/packages/gui/src/components/preview/preview-toolbar.jsx +109 -0
  60. package/packages/gui/src/components/preview/preview-workspace.jsx +278 -0
  61. package/packages/gui/src/components/preview/screenshot-overlay.jsx +237 -0
  62. package/packages/gui/src/components/ui/toast.jsx +6 -2
  63. package/packages/gui/src/stores/groove.js +149 -9
  64. package/packages/gui/src/views/preview.jsx +6 -0
  65. package/packages/gui/src/views/settings.jsx +199 -114
  66. package/welcome.png +0 -0
  67. package/node_modules/@groove-dev/gui/dist/assets/index-DFP3r2yE.js +0 -8615
  68. package/node_modules/@groove-dev/gui/dist/assets/index-QR7lyguO.css +0 -1
  69. package/packages/gui/dist/assets/index-DFP3r2yE.js +0 -8615
  70. package/packages/gui/dist/assets/index-QR7lyguO.css +0 -1
@@ -1,8 +1,9 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useRef, useEffect, useState } from 'react';
3
- import { Copy, Check, ArrowRight } from 'lucide-react';
2
+ import { useRef, useEffect, useState, useCallback } from 'react';
3
+ import { Copy, Check, ArrowRight, Download, Maximize2, X, Image as ImageIcon, RefreshCw } from 'lucide-react';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { timeAgo } from '../../lib/format';
6
+ import { Avatar } from '../ui/avatar';
6
7
  import { ThinkingIndicator } from '../ui/thinking-indicator';
7
8
 
8
9
  const API_STATUS_MESSAGES = [
@@ -12,7 +13,7 @@ const API_STATUS_MESSAGES = [
12
13
  'Almost there...',
13
14
  ];
14
15
 
15
- function CopyButton({ text }) {
16
+ function CopyButton({ text, className }) {
16
17
  const [copied, setCopied] = useState(false);
17
18
  function handleCopy() {
18
19
  navigator.clipboard.writeText(text).then(() => {
@@ -23,7 +24,7 @@ function CopyButton({ text }) {
23
24
  return (
24
25
  <button
25
26
  onClick={handleCopy}
26
- className="flex items-center gap-1 px-2 py-1 text-2xs font-sans text-text-3 hover:text-text-1 transition-colors cursor-pointer"
27
+ className={cn('flex items-center gap-1 px-2 py-1 text-2xs font-sans text-text-3 hover:text-text-1 transition-colors cursor-pointer', className)}
27
28
  >
28
29
  {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
29
30
  {copied ? 'Copied' : 'Copy'}
@@ -54,7 +55,6 @@ function parseMarkdown(text) {
54
55
  while (i < lines.length) {
55
56
  const line = lines[i];
56
57
 
57
- // Fenced code block
58
58
  if (line.startsWith('```')) {
59
59
  const lang = line.slice(3).trim();
60
60
  const codeLines = [];
@@ -63,12 +63,11 @@ function parseMarkdown(text) {
63
63
  codeLines.push(lines[i]);
64
64
  i++;
65
65
  }
66
- i++; // skip closing ```
66
+ i++;
67
67
  blocks.push({ type: 'code', language: lang, code: codeLines.join('\n') });
68
68
  continue;
69
69
  }
70
70
 
71
- // Heading
72
71
  const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
73
72
  if (headingMatch) {
74
73
  blocks.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] });
@@ -76,14 +75,12 @@ function parseMarkdown(text) {
76
75
  continue;
77
76
  }
78
77
 
79
- // Horizontal rule
80
78
  if (/^(-{3,}|_{3,}|\*{3,})$/.test(line.trim())) {
81
79
  blocks.push({ type: 'hr' });
82
80
  i++;
83
81
  continue;
84
82
  }
85
83
 
86
- // Blockquote
87
84
  if (line.startsWith('> ')) {
88
85
  const quoteLines = [line.slice(2)];
89
86
  i++;
@@ -95,7 +92,6 @@ function parseMarkdown(text) {
95
92
  continue;
96
93
  }
97
94
 
98
- // Unordered list
99
95
  if (/^[-*+]\s/.test(line)) {
100
96
  const items = [line.replace(/^[-*+]\s/, '')];
101
97
  i++;
@@ -107,7 +103,6 @@ function parseMarkdown(text) {
107
103
  continue;
108
104
  }
109
105
 
110
- // Ordered list
111
106
  if (/^\d+\.\s/.test(line)) {
112
107
  const items = [line.replace(/^\d+\.\s/, '')];
113
108
  i++;
@@ -119,10 +114,9 @@ function parseMarkdown(text) {
119
114
  continue;
120
115
  }
121
116
 
122
- // Table
123
117
  if (line.includes('|') && i + 1 < lines.length && /^\|?\s*[-:]+/.test(lines[i + 1])) {
124
118
  const headerCells = line.split('|').map((c) => c.trim()).filter(Boolean);
125
- i += 2; // skip header + separator
119
+ i += 2;
126
120
  const rows = [];
127
121
  while (i < lines.length && lines[i].includes('|')) {
128
122
  rows.push(lines[i].split('|').map((c) => c.trim()).filter(Boolean));
@@ -132,13 +126,11 @@ function parseMarkdown(text) {
132
126
  continue;
133
127
  }
134
128
 
135
- // Empty line
136
129
  if (line.trim() === '') {
137
130
  i++;
138
131
  continue;
139
132
  }
140
133
 
141
- // Paragraph — collect consecutive non-empty lines
142
134
  const paraLines = [line];
143
135
  i++;
144
136
  while (i < lines.length && lines[i].trim() !== '' && !lines[i].startsWith('```') && !lines[i].startsWith('#') && !/^[-*+]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i]) && !lines[i].startsWith('> ') && !/^(-{3,}|_{3,}|\*{3,})$/.test(lines[i].trim())) {
@@ -248,8 +240,8 @@ function RenderedMarkdown({ text }) {
248
240
  function UserMessage({ msg }) {
249
241
  return (
250
242
  <div className="flex justify-end">
251
- <div className="max-w-[75%]">
252
- <div className="px-4 py-3 rounded-xl bg-surface-3/80 border border-border-subtle">
243
+ <div className="max-w-[85%]">
244
+ <div className="px-3.5 py-2.5 rounded-2xl rounded-br-md bg-accent/10 border border-accent/15">
253
245
  <p className="text-sm text-text-0 font-sans whitespace-pre-wrap break-words leading-relaxed">{msg.text}</p>
254
246
  </div>
255
247
  <div className="text-2xs text-text-4 font-sans mt-1 text-right">{timeAgo(msg.timestamp)}</div>
@@ -259,17 +251,143 @@ function UserMessage({ msg }) {
259
251
  }
260
252
 
261
253
  function AssistantMessage({ msg, model }) {
254
+ return (
255
+ <div className="flex gap-2.5">
256
+ <Avatar name={model || 'assistant'} role="assistant" size="sm" className="mt-1 flex-shrink-0" />
257
+ <div className="max-w-[85%]">
258
+ {model && <div className="text-2xs text-text-3 font-sans mb-1 font-medium">{model}</div>}
259
+ <div className="text-sm text-text-1 font-sans whitespace-pre-wrap break-words leading-relaxed">
260
+ <RenderedMarkdown text={msg.text} />
261
+ </div>
262
+ <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ function ImageLoadingMessage({ msg }) {
262
269
  return (
263
270
  <div className="max-w-[85%]">
264
- {model && <div className="text-2xs text-text-3 font-sans mb-1 font-medium">{model}</div>}
265
- <div className="border-l-2 border-accent/30 pl-3.5">
266
- <RenderedMarkdown text={msg.text} />
271
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle overflow-hidden">
272
+ <div className="w-80 h-80 image-loading-shimmer flex items-center justify-center">
273
+ <div className="flex flex-col items-center gap-3">
274
+ <div className="w-10 h-10 rounded-full bg-surface-3/80 flex items-center justify-center">
275
+ <ImageIcon size={18} className="text-accent animate-pulse" />
276
+ </div>
277
+ <span className="text-xs text-text-3 font-sans">Generating image...</span>
278
+ </div>
279
+ </div>
280
+ {msg.prompt && (
281
+ <div className="px-4 py-2.5 border-t border-border-subtle">
282
+ <p className="text-2xs text-text-3 font-sans italic truncate">&quot;{msg.prompt}&quot;</p>
283
+ </div>
284
+ )}
267
285
  </div>
268
- <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
269
286
  </div>
270
287
  );
271
288
  }
272
289
 
290
+ function ImageMessage({ msg, onReply }) {
291
+ const [expanded, setExpanded] = useState(false);
292
+ const [hovering, setHovering] = useState(false);
293
+
294
+ const handleDownload = useCallback(() => {
295
+ if (!msg.imageUrl) return;
296
+ const a = document.createElement('a');
297
+ a.href = msg.imageUrl;
298
+ a.download = `groove-${msg.model || 'image'}-${Date.now()}.png`;
299
+ document.body.appendChild(a);
300
+ a.click();
301
+ document.body.removeChild(a);
302
+ }, [msg.imageUrl, msg.model]);
303
+
304
+ return (
305
+ <>
306
+ <div className="max-w-[85%]">
307
+ {msg.model && <div className="text-2xs text-text-3 font-mono mb-1.5 font-medium flex items-center gap-1.5"><ImageIcon size={10} /> {msg.model}</div>}
308
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle overflow-hidden">
309
+ <div
310
+ className="relative group"
311
+ onMouseEnter={() => setHovering(true)}
312
+ onMouseLeave={() => setHovering(false)}
313
+ >
314
+ <img
315
+ src={msg.imageUrl}
316
+ alt={msg.prompt || 'Generated image'}
317
+ className="max-w-full max-h-[480px] object-contain cursor-pointer"
318
+ onClick={() => setExpanded(true)}
319
+ />
320
+ {hovering && (
321
+ <div className="absolute top-2 right-2 flex gap-1.5">
322
+ <button
323
+ onClick={handleDownload}
324
+ className="w-8 h-8 rounded-lg bg-surface-0/90 backdrop-blur-sm border border-border-subtle flex items-center justify-center text-text-2 hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
325
+ title="Download"
326
+ >
327
+ <Download size={14} />
328
+ </button>
329
+ <button
330
+ onClick={() => setExpanded(true)}
331
+ className="w-8 h-8 rounded-lg bg-surface-0/90 backdrop-blur-sm border border-border-subtle flex items-center justify-center text-text-2 hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
332
+ title="Fullscreen"
333
+ >
334
+ <Maximize2 size={14} />
335
+ </button>
336
+ <CopyButton text={msg.prompt || ''} className="h-8 rounded-lg bg-surface-0/90 backdrop-blur-sm border border-border-subtle text-text-2 hover:text-accent" />
337
+ </div>
338
+ )}
339
+ </div>
340
+ <div className="px-4 py-2.5 border-t border-border-subtle flex items-center gap-2">
341
+ <p className="flex-1 text-2xs text-text-3 font-sans italic truncate">&quot;{msg.prompt}&quot;</p>
342
+ {onReply && (
343
+ <button
344
+ onClick={() => onReply(msg)}
345
+ className="text-2xs text-accent hover:text-accent/80 font-sans font-medium cursor-pointer flex items-center gap-1 flex-shrink-0"
346
+ >
347
+ <RefreshCw size={10} /> Iterate
348
+ </button>
349
+ )}
350
+ </div>
351
+ </div>
352
+ <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
353
+ </div>
354
+
355
+ {expanded && (
356
+ <div
357
+ className="fixed inset-0 z-[200] bg-surface-0/95 backdrop-blur-md flex items-center justify-center"
358
+ onClick={() => setExpanded(false)}
359
+ >
360
+ <button
361
+ onClick={() => setExpanded(false)}
362
+ className="absolute top-4 right-4 w-10 h-10 rounded-full bg-surface-3 border border-border flex items-center justify-center text-text-2 hover:text-text-0 transition-colors cursor-pointer z-10"
363
+ >
364
+ <X size={18} />
365
+ </button>
366
+ <div className="absolute bottom-4 right-4 flex gap-2 z-10">
367
+ <button
368
+ onClick={(e) => { e.stopPropagation(); handleDownload(); }}
369
+ className="h-9 px-4 rounded-lg bg-surface-3 border border-border flex items-center gap-2 text-xs font-sans text-text-1 hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
370
+ >
371
+ <Download size={14} /> Download
372
+ </button>
373
+ </div>
374
+ <img
375
+ src={msg.imageUrl}
376
+ alt={msg.prompt || 'Generated image'}
377
+ className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
378
+ onClick={(e) => e.stopPropagation()}
379
+ />
380
+ {msg.prompt && (
381
+ <div className="absolute bottom-4 left-4 max-w-md px-4 py-2 rounded-lg bg-surface-3/90 backdrop-blur-sm border border-border-subtle z-10">
382
+ <p className="text-xs text-text-2 font-sans italic">&quot;{msg.prompt}&quot;</p>
383
+ </div>
384
+ )}
385
+ </div>
386
+ )}
387
+ </>
388
+ );
389
+ }
390
+
273
391
  function SystemMessage({ msg }) {
274
392
  return (
275
393
  <div className="flex justify-center py-1">
@@ -311,12 +429,12 @@ function ApiTypingIndicator() {
311
429
  }, []);
312
430
 
313
431
  return (
314
- <div className="border-l-2 border-accent/30 pl-3.5 py-1 flex items-center gap-2.5">
432
+ <div className="flex items-center gap-2.5 ml-8 py-1">
315
433
  <div className="relative w-3.5 h-3.5 flex-shrink-0">
316
434
  <span className="absolute inset-0 rounded-full border border-transparent border-t-accent animate-spin" style={{ animationDuration: '0.9s' }} />
317
435
  </div>
318
436
  <span
319
- className="text-[12px] font-sans text-text-3 transition-opacity duration-[250ms]"
437
+ className="text-2xs font-sans text-text-3 transition-opacity duration-[250ms]"
320
438
  style={{ opacity: fade ? 1 : 0 }}
321
439
  >
322
440
  {API_STATUS_MESSAGES[idx]}
@@ -325,7 +443,7 @@ function ApiTypingIndicator() {
325
443
  );
326
444
  }
327
445
 
328
- export function ChatMessages({ messages, isStreaming, model, mode }) {
446
+ export function ChatMessages({ messages, isStreaming, model, mode, onImageReply }) {
329
447
  const scrollRef = useRef(null);
330
448
  const isAtBottomRef = useRef(true);
331
449
 
@@ -356,6 +474,8 @@ export function ChatMessages({ messages, isStreaming, model, mode }) {
356
474
  return (
357
475
  <div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
358
476
  {messages.map((msg, i) => {
477
+ if (msg.type === 'image-loading') return <ImageLoadingMessage key={i} msg={msg} />;
478
+ if (msg.type === 'image') return <ImageMessage key={i} msg={msg} onReply={onImageReply} />;
359
479
  if (msg.from === 'user') return <UserMessage key={i} msg={msg} />;
360
480
  if (msg.from === 'system') return <SystemMessage key={i} msg={msg} />;
361
481
  return <AssistantMessage key={i} msg={msg} model={model} />;
@@ -7,6 +7,7 @@ import { ConversationList } from './conversation-list';
7
7
  import { ChatHeader } from './chat-header';
8
8
  import { ChatMessages } from './chat-messages';
9
9
  import { ChatInput } from './chat-input';
10
+ import { isImageModel } from './model-picker';
10
11
 
11
12
  function EmptyState({ onNewChat }) {
12
13
  return (
@@ -35,16 +36,19 @@ export function ChatView() {
35
36
  const createConversation = useGrooveStore((s) => s.createConversation);
36
37
  const setActiveConversation = useGrooveStore((s) => s.setActiveConversation);
37
38
  const sendChatMessage = useGrooveStore((s) => s.sendChatMessage);
39
+ const sendImageMessage = useGrooveStore((s) => s.sendImageMessage);
38
40
  const stopAgent = useGrooveStore((s) => s.stopAgent);
39
41
  const stopChatStreaming = useGrooveStore((s) => s.stopChatStreaming);
40
42
  const setConversationMode = useGrooveStore((s) => s.setConversationMode);
41
43
  const setConversationModel = useGrooveStore((s) => s.setConversationModel);
42
44
 
43
45
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
46
+ const [replyContext, setReplyContext] = useState(null);
44
47
 
45
48
  const activeConversation = conversations.find((c) => c.id === activeConversationId) || null;
46
49
  const messages = activeConversationId ? (conversationMessages[activeConversationId] || []) : [];
47
50
  const isStreaming = streamingConversationId === activeConversationId && sendingMessage;
51
+ const currentModelIsImage = activeConversation ? isImageModel(activeConversation.model) : false;
48
52
 
49
53
  const handleNewChat = useCallback(async (provider, model) => {
50
54
  const p = provider || 'claude-code';
@@ -61,8 +65,17 @@ export function ChatView() {
61
65
 
62
66
  const handleSend = useCallback((text) => {
63
67
  if (!activeConversationId) return;
64
- sendChatMessage(activeConversationId, text);
65
- }, [activeConversationId, sendChatMessage]);
68
+
69
+ if (currentModelIsImage) {
70
+ const prompt = replyContext
71
+ ? `${text} (iterating on: "${replyContext.prompt}")`
72
+ : text;
73
+ sendImageMessage(activeConversationId, prompt, { model: activeConversation.model });
74
+ setReplyContext(null);
75
+ } else {
76
+ sendChatMessage(activeConversationId, text);
77
+ }
78
+ }, [activeConversationId, activeConversation, currentModelIsImage, replyContext, sendChatMessage, sendImageMessage]);
66
79
 
67
80
  const handleStop = useCallback(() => {
68
81
  if (!activeConversation) return;
@@ -81,6 +94,10 @@ export function ChatView() {
81
94
  }
82
95
  }, [activeConversationId, setConversationModel, handleNewChat]);
83
96
 
97
+ const handleImageReply = useCallback((msg) => {
98
+ setReplyContext(msg);
99
+ }, []);
100
+
84
101
  const currentModel = activeConversation
85
102
  ? { provider: activeConversation.provider, model: activeConversation.model }
86
103
  : null;
@@ -105,6 +122,7 @@ export function ChatView() {
105
122
  isStreaming={isStreaming}
106
123
  model={activeConversation.model}
107
124
  mode={activeConversation.mode || 'api'}
125
+ onImageReply={handleImageReply}
108
126
  />
109
127
  <ChatInput
110
128
  onSend={handleSend}
@@ -112,6 +130,10 @@ export function ChatView() {
112
130
  sending={sendingMessage}
113
131
  streaming={isStreaming}
114
132
  disabled={false}
133
+ isImageModel={currentModelIsImage}
134
+ currentModel={activeConversation.model}
135
+ replyContext={replyContext}
136
+ onClearReply={() => setReplyContext(null)}
115
137
  />
116
138
  </>
117
139
  ) : (
@@ -130,6 +152,8 @@ export function ChatView() {
130
152
  sending={false}
131
153
  streaming={false}
132
154
  disabled={false}
155
+ isImageModel={false}
156
+ currentModel={null}
133
157
  />
134
158
  </>
135
159
  )}
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useEffect, useRef } from 'react';
3
- import { ChevronDown, Globe, Cpu, Zap, Sparkles } from 'lucide-react';
3
+ import { ChevronDown, Globe, Cpu, Zap, Sparkles, Image as ImageIcon } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { Badge } from '../ui/badge';
@@ -38,6 +38,12 @@ function getContextSize(model) {
38
38
  return '128k';
39
39
  }
40
40
 
41
+ export function isImageModel(modelId) {
42
+ if (!modelId) return false;
43
+ const id = modelId.toLowerCase();
44
+ return id.includes('dall-e') || id.includes('imagen') || id.includes('gpt-image') || id.includes('imagine') || id.includes('nano-banana');
45
+ }
46
+
41
47
  export function ModelPicker({ value, onChange, disabled }) {
42
48
  const [open, setOpen] = useState(false);
43
49
  const [providers, setProviders] = useState([]);
@@ -64,6 +70,99 @@ export function ModelPicker({ value, onChange, disabled }) {
64
70
  const currentModelDisplay = currentModel ? formatModelName(currentModel) : 'Select model';
65
71
  const currentProvider = value?.provider || '';
66
72
  const isNetwork = currentProvider === 'groove-network';
73
+ const isCurrentImage = isImageModel(currentModel);
74
+
75
+ const chatModels = [];
76
+ const imageModels = [];
77
+
78
+ for (const provider of providers) {
79
+ const models = provider.models || [];
80
+ const chatGroup = [];
81
+ const imgGroup = [];
82
+ for (const model of models) {
83
+ const modelId = typeof model === 'string' ? model : model.id || model.name;
84
+ const modelType = typeof model === 'object' ? model.type : undefined;
85
+ if (modelType === 'image' || isImageModel(modelId)) {
86
+ imgGroup.push(model);
87
+ } else {
88
+ chatGroup.push(model);
89
+ }
90
+ }
91
+ if (chatGroup.length > 0) chatModels.push({ ...provider, models: chatGroup });
92
+ if (imgGroup.length > 0) imageModels.push({ ...provider, models: imgGroup });
93
+ }
94
+
95
+ function renderModelGroup(providerList, sectionLabel) {
96
+ if (providerList.length === 0) return null;
97
+ return (
98
+ <>
99
+ {sectionLabel && (
100
+ <div className="px-3 py-1.5 text-2xs font-semibold text-text-4 uppercase tracking-wider font-sans bg-surface-0 border-b border-border-subtle flex items-center gap-1.5">
101
+ {sectionLabel === 'Image Generation' && <ImageIcon size={10} className="text-purple" />}
102
+ {sectionLabel}
103
+ </div>
104
+ )}
105
+ {providerList.map((provider) => {
106
+ const isNetworkProvider = provider.id === 'groove-network';
107
+ return (
108
+ <div key={provider.id}>
109
+ <div className="px-3 py-1.5 text-2xs font-semibold text-text-3 uppercase tracking-wider font-sans bg-surface-2 border-b border-border-subtle flex items-center gap-1.5">
110
+ {isNetworkProvider && <Globe size={10} className="text-purple" />}
111
+ {provider.name || provider.id}
112
+ </div>
113
+ {provider.models.map((model) => {
114
+ const modelId = typeof model === 'string' ? model : model.id || model.name;
115
+ const modelDisplayName = typeof model === 'string' ? model : model.name || model.id;
116
+ const modelType = typeof model === 'object' ? model.type : undefined;
117
+ const isImg = modelType === 'image' || isImageModel(modelId);
118
+ const tier = isImg ? null : getTier(modelId);
119
+ const tierConfig = tier ? TIER_CONFIG[tier] : null;
120
+ const TierIcon = tierConfig?.icon;
121
+ const isActive = currentModel === modelId && currentProvider === provider.id;
122
+ return (
123
+ <button
124
+ key={modelId}
125
+ onClick={() => {
126
+ onChange({ provider: provider.id, model: modelId });
127
+ setOpen(false);
128
+ }}
129
+ className={cn(
130
+ 'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors cursor-pointer',
131
+ isActive ? 'bg-accent/10 text-text-0' : 'hover:bg-surface-3 text-text-1',
132
+ )}
133
+ >
134
+ <div className="flex-1 min-w-0">
135
+ <div className="flex items-center gap-1.5">
136
+ {isImg && <ImageIcon size={11} className="text-purple flex-shrink-0" />}
137
+ <span className="text-xs font-medium font-sans truncate">{modelDisplayName}</span>
138
+ </div>
139
+ {!isImg && <div className="text-2xs text-text-4 font-sans">{getContextSize(modelId)} context</div>}
140
+ </div>
141
+ <div className="flex items-center gap-1.5 flex-shrink-0">
142
+ {isNetworkProvider && (
143
+ <Badge variant="purple" className="text-[9px]">
144
+ <Globe size={8} /> Decentralized
145
+ </Badge>
146
+ )}
147
+ {isImg ? (
148
+ <Badge variant="purple" className="text-[9px]">
149
+ <ImageIcon size={8} /> Image
150
+ </Badge>
151
+ ) : tierConfig && (
152
+ <Badge variant={tierConfig.variant} className="text-[9px]">
153
+ <TierIcon size={8} /> {tierConfig.label}
154
+ </Badge>
155
+ )}
156
+ </div>
157
+ </button>
158
+ );
159
+ })}
160
+ </div>
161
+ );
162
+ })}
163
+ </>
164
+ );
165
+ }
67
166
 
68
167
  return (
69
168
  <div ref={ref} className="relative">
@@ -75,10 +174,11 @@ export function ModelPicker({ value, onChange, disabled }) {
75
174
  'bg-surface-4 border border-border-subtle hover:bg-surface-5',
76
175
  'disabled:opacity-40 disabled:cursor-not-allowed',
77
176
  isNetwork && 'border-purple/30 bg-purple/8',
177
+ isCurrentImage && 'border-purple/30 bg-purple/8',
78
178
  )}
79
179
  >
80
- {isNetwork ? <Globe size={12} className="text-purple" /> : <Cpu size={12} className="text-text-3" />}
81
- <span className="text-text-1 max-w-[120px] truncate">{currentModelDisplay}</span>
180
+ {isCurrentImage ? <ImageIcon size={12} className="text-purple" /> : isNetwork ? <Globe size={12} className="text-purple" /> : <Cpu size={12} className="text-text-3" />}
181
+ <span className={cn('max-w-[120px] truncate', isCurrentImage ? 'text-purple' : 'text-text-1')}>{currentModelDisplay}</span>
82
182
  <ChevronDown size={12} className="text-text-4" />
83
183
  </button>
84
184
 
@@ -87,55 +187,8 @@ export function ModelPicker({ value, onChange, disabled }) {
87
187
  {providers.length === 0 && (
88
188
  <div className="px-4 py-6 text-center text-xs text-text-3 font-sans">No providers available</div>
89
189
  )}
90
- {providers.map((provider) => {
91
- const models = provider.models || [];
92
- if (models.length === 0) return null;
93
- const isNetworkProvider = provider.id === 'groove-network';
94
- return (
95
- <div key={provider.id}>
96
- <div className="px-3 py-1.5 text-2xs font-semibold text-text-3 uppercase tracking-wider font-sans bg-surface-2 border-b border-border-subtle flex items-center gap-1.5">
97
- {isNetworkProvider && <Globe size={10} className="text-purple" />}
98
- {provider.name || provider.id}
99
- </div>
100
- {models.map((model) => {
101
- const modelId = typeof model === 'string' ? model : model.id || model.name;
102
- const modelDisplayName = typeof model === 'string' ? model : model.name || model.id;
103
- const tier = getTier(modelId);
104
- const tierConfig = TIER_CONFIG[tier];
105
- const TierIcon = tierConfig.icon;
106
- const isActive = currentModel === modelId && currentProvider === provider.id;
107
- return (
108
- <button
109
- key={modelId}
110
- onClick={() => {
111
- onChange({ provider: provider.id, model: modelId });
112
- setOpen(false);
113
- }}
114
- className={cn(
115
- 'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors cursor-pointer',
116
- isActive ? 'bg-accent/10 text-text-0' : 'hover:bg-surface-3 text-text-1',
117
- )}
118
- >
119
- <div className="flex-1 min-w-0">
120
- <div className="text-xs font-medium font-sans truncate">{modelDisplayName}</div>
121
- <div className="text-2xs text-text-4 font-sans">{getContextSize(modelId)} context</div>
122
- </div>
123
- <div className="flex items-center gap-1.5 flex-shrink-0">
124
- {isNetworkProvider && (
125
- <Badge variant="purple" className="text-[9px]">
126
- <Globe size={8} /> Decentralized
127
- </Badge>
128
- )}
129
- <Badge variant={tierConfig.variant} className="text-[9px]">
130
- <TierIcon size={8} /> {tierConfig.label}
131
- </Badge>
132
- </div>
133
- </button>
134
- );
135
- })}
136
- </div>
137
- );
138
- })}
190
+ {renderModelGroup(chatModels, null)}
191
+ {renderModelGroup(imageModels, 'Image Generation')}
139
192
  </div>
140
193
  )}
141
194
  </div>
@@ -1,5 +1,5 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { Network, Code2, ChartSpline, Puzzle, Gamepad2, Users, Box, Newspaper, Settings, Globe, MessageCircle } from 'lucide-react';
2
+ import { Network, Code2, ChartSpline, Puzzle, Gamepad2, Users, Box, Newspaper, Settings, Globe, MessageCircle, Eye } from 'lucide-react';
3
3
  import { cn } from '../../lib/cn';
4
4
  import { Tooltip } from '../ui/tooltip';
5
5
  import { useGrooveStore } from '../../stores/groove';
@@ -17,6 +17,7 @@ const BASE_NAV_ITEMS = [
17
17
  ];
18
18
 
19
19
  const NETWORK_NAV_ITEM = { id: 'network', icon: Globe, label: 'Network' };
20
+ const PREVIEW_NAV_ITEM = { id: 'preview', icon: Eye, label: 'Preview' };
20
21
 
21
22
  const UTIL_ITEMS = [
22
23
  { id: 'journalist', icon: Newspaper, label: 'Journalist', panel: true },
@@ -26,7 +27,9 @@ const UTIL_ITEMS = [
26
27
  export function ActivityBar({ activeView, detailPanel, onNavigate, onTogglePanel }) {
27
28
  const darwinTrafficLights = isElectron() && getPlatform() === 'darwin';
28
29
  const networkUnlocked = useGrooveStore((s) => s.networkUnlocked);
29
- const NAV_ITEMS = networkUnlocked ? [...BASE_NAV_ITEMS, NETWORK_NAV_ITEM] : BASE_NAV_ITEMS;
30
+ const previewUrl = useGrooveStore((s) => s.previewState.url);
31
+ let NAV_ITEMS = previewUrl ? [...BASE_NAV_ITEMS, PREVIEW_NAV_ITEM] : BASE_NAV_ITEMS;
32
+ if (networkUnlocked) NAV_ITEMS = [...NAV_ITEMS, NETWORK_NAV_ITEM];
30
33
 
31
34
  return (
32
35
  <nav className="w-12 flex-shrink-0 flex flex-col bg-surface-3 border-r border-border">