groove-dev 0.27.73 → 0.27.75

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 (73) 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 +18 -2
  7. package/node_modules/@groove-dev/daemon/src/process.js +6 -1
  8. package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +2 -1
  10. package/node_modules/@groove-dev/daemon/src/providers/codex.js +41 -1
  11. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +2 -1
  12. package/node_modules/@groove-dev/daemon/src/providers/grok.js +156 -0
  13. package/node_modules/@groove-dev/daemon/src/providers/index.js +26 -9
  14. package/node_modules/@groove-dev/daemon/src/providers/nano-banana.js +103 -0
  15. package/node_modules/@groove-dev/gui/dist/assets/index-CAT9SCJi.js +8620 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/index-CVzz6zyb.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  18. package/node_modules/@groove-dev/gui/package.json +1 -1
  19. package/node_modules/@groove-dev/gui/src/app.css +29 -0
  20. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  21. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +16 -5
  22. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +40 -7
  23. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +149 -31
  24. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -2
  25. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +105 -52
  26. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +5 -2
  27. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +215 -88
  28. package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +81 -0
  29. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +263 -0
  30. package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +203 -0
  31. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +6 -2
  32. package/node_modules/@groove-dev/gui/src/stores/groove.js +149 -9
  33. package/node_modules/@groove-dev/gui/src/views/preview.jsx +6 -0
  34. package/node_modules/@groove-dev/gui/src/views/settings.jsx +278 -123
  35. package/package.json +1 -1
  36. package/packages/cli/package.json +1 -1
  37. package/packages/daemon/package.json +1 -1
  38. package/packages/daemon/src/api.js +256 -4
  39. package/packages/daemon/src/conversations.js +16 -0
  40. package/packages/daemon/src/index.js +41 -1
  41. package/packages/daemon/src/preview.js +18 -2
  42. package/packages/daemon/src/process.js +6 -1
  43. package/packages/daemon/src/providers/base.js +4 -0
  44. package/packages/daemon/src/providers/claude-code.js +2 -1
  45. package/packages/daemon/src/providers/codex.js +41 -1
  46. package/packages/daemon/src/providers/gemini.js +2 -1
  47. package/packages/daemon/src/providers/grok.js +156 -0
  48. package/packages/daemon/src/providers/index.js +26 -9
  49. package/packages/daemon/src/providers/nano-banana.js +103 -0
  50. package/packages/gui/dist/assets/index-CAT9SCJi.js +8620 -0
  51. package/packages/gui/dist/assets/index-CVzz6zyb.css +1 -0
  52. package/packages/gui/dist/index.html +2 -2
  53. package/packages/gui/package.json +1 -1
  54. package/packages/gui/src/app.css +29 -0
  55. package/packages/gui/src/app.jsx +2 -0
  56. package/packages/gui/src/components/chat/chat-header.jsx +16 -5
  57. package/packages/gui/src/components/chat/chat-input.jsx +40 -7
  58. package/packages/gui/src/components/chat/chat-messages.jsx +149 -31
  59. package/packages/gui/src/components/chat/chat-view.jsx +26 -2
  60. package/packages/gui/src/components/chat/model-picker.jsx +105 -52
  61. package/packages/gui/src/components/layout/activity-bar.jsx +5 -2
  62. package/packages/gui/src/components/layout/welcome-splash.jsx +215 -88
  63. package/packages/gui/src/components/preview/preview-toolbar.jsx +81 -0
  64. package/packages/gui/src/components/preview/preview-workspace.jsx +263 -0
  65. package/packages/gui/src/components/preview/screenshot-overlay.jsx +203 -0
  66. package/packages/gui/src/components/ui/toast.jsx +6 -2
  67. package/packages/gui/src/stores/groove.js +149 -9
  68. package/packages/gui/src/views/preview.jsx +6 -0
  69. package/packages/gui/src/views/settings.jsx +278 -123
  70. package/node_modules/@groove-dev/gui/dist/assets/index-BFc7Ov6v.css +0 -1
  71. package/node_modules/@groove-dev/gui/dist/assets/index-Deza1S0i.js +0 -8615
  72. package/packages/gui/dist/assets/index-BFc7Ov6v.css +0 -1
  73. package/packages/gui/dist/assets/index-Deza1S0i.js +0 -8615
@@ -1,6 +1,6 @@
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
6
  import { ThinkingIndicator } from '../ui/thinking-indicator';
@@ -12,7 +12,7 @@ const API_STATUS_MESSAGES = [
12
12
  'Almost there...',
13
13
  ];
14
14
 
15
- function CopyButton({ text }) {
15
+ function CopyButton({ text, className }) {
16
16
  const [copied, setCopied] = useState(false);
17
17
  function handleCopy() {
18
18
  navigator.clipboard.writeText(text).then(() => {
@@ -23,7 +23,7 @@ function CopyButton({ text }) {
23
23
  return (
24
24
  <button
25
25
  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"
26
+ 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
27
  >
28
28
  {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
29
29
  {copied ? 'Copied' : 'Copy'}
@@ -54,7 +54,6 @@ function parseMarkdown(text) {
54
54
  while (i < lines.length) {
55
55
  const line = lines[i];
56
56
 
57
- // Fenced code block
58
57
  if (line.startsWith('```')) {
59
58
  const lang = line.slice(3).trim();
60
59
  const codeLines = [];
@@ -63,12 +62,11 @@ function parseMarkdown(text) {
63
62
  codeLines.push(lines[i]);
64
63
  i++;
65
64
  }
66
- i++; // skip closing ```
65
+ i++;
67
66
  blocks.push({ type: 'code', language: lang, code: codeLines.join('\n') });
68
67
  continue;
69
68
  }
70
69
 
71
- // Heading
72
70
  const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
73
71
  if (headingMatch) {
74
72
  blocks.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] });
@@ -76,14 +74,12 @@ function parseMarkdown(text) {
76
74
  continue;
77
75
  }
78
76
 
79
- // Horizontal rule
80
77
  if (/^(-{3,}|_{3,}|\*{3,})$/.test(line.trim())) {
81
78
  blocks.push({ type: 'hr' });
82
79
  i++;
83
80
  continue;
84
81
  }
85
82
 
86
- // Blockquote
87
83
  if (line.startsWith('> ')) {
88
84
  const quoteLines = [line.slice(2)];
89
85
  i++;
@@ -95,7 +91,6 @@ function parseMarkdown(text) {
95
91
  continue;
96
92
  }
97
93
 
98
- // Unordered list
99
94
  if (/^[-*+]\s/.test(line)) {
100
95
  const items = [line.replace(/^[-*+]\s/, '')];
101
96
  i++;
@@ -107,7 +102,6 @@ function parseMarkdown(text) {
107
102
  continue;
108
103
  }
109
104
 
110
- // Ordered list
111
105
  if (/^\d+\.\s/.test(line)) {
112
106
  const items = [line.replace(/^\d+\.\s/, '')];
113
107
  i++;
@@ -119,10 +113,9 @@ function parseMarkdown(text) {
119
113
  continue;
120
114
  }
121
115
 
122
- // Table
123
116
  if (line.includes('|') && i + 1 < lines.length && /^\|?\s*[-:]+/.test(lines[i + 1])) {
124
117
  const headerCells = line.split('|').map((c) => c.trim()).filter(Boolean);
125
- i += 2; // skip header + separator
118
+ i += 2;
126
119
  const rows = [];
127
120
  while (i < lines.length && lines[i].includes('|')) {
128
121
  rows.push(lines[i].split('|').map((c) => c.trim()).filter(Boolean));
@@ -132,13 +125,11 @@ function parseMarkdown(text) {
132
125
  continue;
133
126
  }
134
127
 
135
- // Empty line
136
128
  if (line.trim() === '') {
137
129
  i++;
138
130
  continue;
139
131
  }
140
132
 
141
- // Paragraph — collect consecutive non-empty lines
142
133
  const paraLines = [line];
143
134
  i++;
144
135
  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())) {
@@ -247,9 +238,9 @@ function RenderedMarkdown({ text }) {
247
238
 
248
239
  function UserMessage({ msg }) {
249
240
  return (
250
- <div className="flex justify-end">
241
+ <div className="flex justify-end animate-chat-fade-in">
251
242
  <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="px-4 py-3 rounded-2xl rounded-br-md bg-gradient-to-br from-accent/12 to-accent/6 border border-accent/15 backdrop-blur-sm">
253
244
  <p className="text-sm text-text-0 font-sans whitespace-pre-wrap break-words leading-relaxed">{msg.text}</p>
254
245
  </div>
255
246
  <div className="text-2xs text-text-4 font-sans mt-1 text-right">{timeAgo(msg.timestamp)}</div>
@@ -260,9 +251,9 @@ function UserMessage({ msg }) {
260
251
 
261
252
  function AssistantMessage({ msg, model }) {
262
253
  return (
263
- <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">
254
+ <div className="max-w-[85%] animate-chat-fade-in">
255
+ {model && <div className="text-2xs text-text-3 font-mono mb-1.5 font-medium">{model}</div>}
256
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle px-4 py-3">
266
257
  <RenderedMarkdown text={msg.text} />
267
258
  </div>
268
259
  <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
@@ -270,9 +261,132 @@ function AssistantMessage({ msg, model }) {
270
261
  );
271
262
  }
272
263
 
264
+ function ImageLoadingMessage({ msg }) {
265
+ return (
266
+ <div className="max-w-[85%] animate-chat-fade-in">
267
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle overflow-hidden">
268
+ <div className="w-80 h-80 image-loading-shimmer flex items-center justify-center">
269
+ <div className="flex flex-col items-center gap-3">
270
+ <div className="w-10 h-10 rounded-full bg-surface-3/80 flex items-center justify-center">
271
+ <ImageIcon size={18} className="text-accent animate-pulse" />
272
+ </div>
273
+ <span className="text-xs text-text-3 font-sans">Generating image...</span>
274
+ </div>
275
+ </div>
276
+ {msg.prompt && (
277
+ <div className="px-4 py-2.5 border-t border-border-subtle">
278
+ <p className="text-2xs text-text-3 font-sans italic truncate">&quot;{msg.prompt}&quot;</p>
279
+ </div>
280
+ )}
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ function ImageMessage({ msg, onReply }) {
287
+ const [expanded, setExpanded] = useState(false);
288
+ const [hovering, setHovering] = useState(false);
289
+
290
+ const handleDownload = useCallback(() => {
291
+ if (!msg.imageUrl) return;
292
+ const a = document.createElement('a');
293
+ a.href = msg.imageUrl;
294
+ a.download = `groove-${msg.model || 'image'}-${Date.now()}.png`;
295
+ document.body.appendChild(a);
296
+ a.click();
297
+ document.body.removeChild(a);
298
+ }, [msg.imageUrl, msg.model]);
299
+
300
+ return (
301
+ <>
302
+ <div className="max-w-[85%] animate-chat-fade-in">
303
+ {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>}
304
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle overflow-hidden">
305
+ <div
306
+ className="relative group"
307
+ onMouseEnter={() => setHovering(true)}
308
+ onMouseLeave={() => setHovering(false)}
309
+ >
310
+ <img
311
+ src={msg.imageUrl}
312
+ alt={msg.prompt || 'Generated image'}
313
+ className="max-w-full max-h-[480px] object-contain cursor-pointer"
314
+ onClick={() => setExpanded(true)}
315
+ />
316
+ {hovering && (
317
+ <div className="absolute top-2 right-2 flex gap-1.5 animate-chat-fade-in">
318
+ <button
319
+ onClick={handleDownload}
320
+ 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"
321
+ title="Download"
322
+ >
323
+ <Download size={14} />
324
+ </button>
325
+ <button
326
+ onClick={() => setExpanded(true)}
327
+ 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"
328
+ title="Fullscreen"
329
+ >
330
+ <Maximize2 size={14} />
331
+ </button>
332
+ <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" />
333
+ </div>
334
+ )}
335
+ </div>
336
+ <div className="px-4 py-2.5 border-t border-border-subtle flex items-center gap-2">
337
+ <p className="flex-1 text-2xs text-text-3 font-sans italic truncate">&quot;{msg.prompt}&quot;</p>
338
+ {onReply && (
339
+ <button
340
+ onClick={() => onReply(msg)}
341
+ className="text-2xs text-accent hover:text-accent/80 font-sans font-medium cursor-pointer flex items-center gap-1 flex-shrink-0"
342
+ >
343
+ <RefreshCw size={10} /> Iterate
344
+ </button>
345
+ )}
346
+ </div>
347
+ </div>
348
+ <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
349
+ </div>
350
+
351
+ {expanded && (
352
+ <div
353
+ className="fixed inset-0 z-[200] bg-surface-0/95 backdrop-blur-md flex items-center justify-center animate-chat-fade-in"
354
+ onClick={() => setExpanded(false)}
355
+ >
356
+ <button
357
+ onClick={() => setExpanded(false)}
358
+ 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"
359
+ >
360
+ <X size={18} />
361
+ </button>
362
+ <div className="absolute bottom-4 right-4 flex gap-2 z-10">
363
+ <button
364
+ onClick={(e) => { e.stopPropagation(); handleDownload(); }}
365
+ 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"
366
+ >
367
+ <Download size={14} /> Download
368
+ </button>
369
+ </div>
370
+ <img
371
+ src={msg.imageUrl}
372
+ alt={msg.prompt || 'Generated image'}
373
+ className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
374
+ onClick={(e) => e.stopPropagation()}
375
+ />
376
+ {msg.prompt && (
377
+ <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">
378
+ <p className="text-xs text-text-2 font-sans italic">&quot;{msg.prompt}&quot;</p>
379
+ </div>
380
+ )}
381
+ </div>
382
+ )}
383
+ </>
384
+ );
385
+ }
386
+
273
387
  function SystemMessage({ msg }) {
274
388
  return (
275
- <div className="flex justify-center py-1">
389
+ <div className="flex justify-center py-1 animate-chat-fade-in">
276
390
  <div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-surface-4/50">
277
391
  <ArrowRight size={10} className="text-text-4" />
278
392
  <span className="text-2xs text-text-3 font-sans">{msg.text}</span>
@@ -311,21 +425,23 @@ function ApiTypingIndicator() {
311
425
  }, []);
312
426
 
313
427
  return (
314
- <div className="border-l-2 border-accent/30 pl-3.5 py-1 flex items-center gap-2.5">
315
- <div className="relative w-3.5 h-3.5 flex-shrink-0">
316
- <span className="absolute inset-0 rounded-full border border-transparent border-t-accent animate-spin" style={{ animationDuration: '0.9s' }} />
428
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle px-4 py-3 max-w-[85%] animate-chat-fade-in">
429
+ <div className="flex items-center gap-2.5">
430
+ <div className="relative w-3.5 h-3.5 flex-shrink-0">
431
+ <span className="absolute inset-0 rounded-full border border-transparent border-t-accent animate-spin" style={{ animationDuration: '0.9s' }} />
432
+ </div>
433
+ <span
434
+ className="text-[12px] font-sans text-text-3 transition-opacity duration-[250ms]"
435
+ style={{ opacity: fade ? 1 : 0 }}
436
+ >
437
+ {API_STATUS_MESSAGES[idx]}
438
+ </span>
317
439
  </div>
318
- <span
319
- className="text-[12px] font-sans text-text-3 transition-opacity duration-[250ms]"
320
- style={{ opacity: fade ? 1 : 0 }}
321
- >
322
- {API_STATUS_MESSAGES[idx]}
323
- </span>
324
440
  </div>
325
441
  );
326
442
  }
327
443
 
328
- export function ChatMessages({ messages, isStreaming, model, mode }) {
444
+ export function ChatMessages({ messages, isStreaming, model, mode, onImageReply }) {
329
445
  const scrollRef = useRef(null);
330
446
  const isAtBottomRef = useRef(true);
331
447
 
@@ -356,6 +472,8 @@ export function ChatMessages({ messages, isStreaming, model, mode }) {
356
472
  return (
357
473
  <div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
358
474
  {messages.map((msg, i) => {
475
+ if (msg.type === 'image-loading') return <ImageLoadingMessage key={i} msg={msg} />;
476
+ if (msg.type === 'image') return <ImageMessage key={i} msg={msg} onReply={onImageReply} />;
359
477
  if (msg.from === 'user') return <UserMessage key={i} msg={msg} />;
360
478
  if (msg.from === 'system') return <SystemMessage key={i} msg={msg} />;
361
479
  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">