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
@@ -0,0 +1,263 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useRef, useCallback, useEffect } from 'react';
3
+ import { Send, Paperclip, MonitorX, Loader2 } from 'lucide-react';
4
+ import { useGrooveStore } from '../../stores/groove';
5
+ import { cn } from '../../lib/cn';
6
+ import { timeAgo } from '../../lib/format';
7
+ import { PreviewToolbar } from './preview-toolbar';
8
+ import { ScreenshotOverlay } from './screenshot-overlay';
9
+
10
+ function RenderedMarkdown({ text }) {
11
+ if (!text) return null;
12
+ const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g);
13
+ return (
14
+ <>
15
+ {parts.map((part, i) => {
16
+ if (!part) return null;
17
+ if (part.startsWith('```') && part.endsWith('```')) {
18
+ const inner = part.slice(3, -3);
19
+ const nl = inner.indexOf('\n');
20
+ const code = nl >= 0 ? inner.slice(nl + 1) : inner;
21
+ return (
22
+ <pre key={i} className="my-2 px-3 py-2 rounded-lg bg-surface-0 border border-border-subtle overflow-x-auto">
23
+ <code className="text-xs font-mono text-text-1 whitespace-pre">{code}</code>
24
+ </pre>
25
+ );
26
+ }
27
+ if (part.startsWith('`') && part.endsWith('`')) {
28
+ return <code key={i} className="px-1.5 py-0.5 rounded bg-surface-0 text-xs font-mono text-accent">{part.slice(1, -1)}</code>;
29
+ }
30
+ if (part.startsWith('**') && part.endsWith('**')) {
31
+ return <strong key={i} className="font-semibold text-text-0">{part.slice(2, -2)}</strong>;
32
+ }
33
+ if (part.startsWith('*') && part.endsWith('*')) {
34
+ return <em key={i} className="italic">{part.slice(1, -1)}</em>;
35
+ }
36
+ return <span key={i}>{part}</span>;
37
+ })}
38
+ </>
39
+ );
40
+ }
41
+
42
+ function PreviewChatMessage({ msg }) {
43
+ if (msg.role === 'user') {
44
+ return (
45
+ <div className="flex justify-end animate-chat-fade-in">
46
+ <div className="max-w-[85%]">
47
+ {msg.screenshot && (
48
+ <img src={msg.screenshot} alt="Screenshot" className="mb-2 rounded-lg border border-border-subtle max-h-40 object-contain" />
49
+ )}
50
+ <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">
51
+ <p className="text-sm text-text-0 font-sans whitespace-pre-wrap break-words leading-relaxed">{msg.content}</p>
52
+ </div>
53
+ <div className="text-2xs text-text-4 font-sans mt-1 text-right">{timeAgo(msg.timestamp)}</div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <div className="max-w-[85%] animate-chat-fade-in">
61
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle px-4 py-3">
62
+ <p className="text-sm text-text-1 font-sans whitespace-pre-wrap break-words leading-relaxed">
63
+ <RenderedMarkdown text={msg.content} />
64
+ </p>
65
+ </div>
66
+ <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function PreviewChat() {
72
+ const previewChat = useGrooveStore((s) => s.previewChat);
73
+ const previewState = useGrooveStore((s) => s.previewState);
74
+ const iteratePreview = useGrooveStore((s) => s.iteratePreview);
75
+ const previewIterating = useGrooveStore((s) => s.previewIterating);
76
+
77
+ const [input, setInput] = useState('');
78
+ const scrollRef = useRef(null);
79
+ const textareaRef = useRef(null);
80
+ const isAtBottomRef = useRef(true);
81
+
82
+ useEffect(() => {
83
+ const el = scrollRef.current;
84
+ if (!el) return;
85
+ function handleScroll() {
86
+ isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
87
+ }
88
+ el.addEventListener('scroll', handleScroll);
89
+ return () => el.removeEventListener('scroll', handleScroll);
90
+ }, []);
91
+
92
+ useEffect(() => {
93
+ if (isAtBottomRef.current && scrollRef.current) {
94
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
95
+ }
96
+ }, [previewChat?.length]);
97
+
98
+ const adjustHeight = useCallback(() => {
99
+ const el = textareaRef.current;
100
+ if (!el) return;
101
+ el.style.height = 'auto';
102
+ el.style.height = Math.min(el.scrollHeight, 160) + 'px';
103
+ }, []);
104
+
105
+ useEffect(() => { adjustHeight(); }, [input, adjustHeight]);
106
+
107
+ function handleSend() {
108
+ const text = input.trim();
109
+ if (!text || previewIterating) return;
110
+ iteratePreview(text);
111
+ setInput('');
112
+ if (textareaRef.current) textareaRef.current.style.height = 'auto';
113
+ }
114
+
115
+ function onKeyDown(e) {
116
+ if (e.key === 'Enter' && !e.shiftKey) {
117
+ e.preventDefault();
118
+ handleSend();
119
+ }
120
+ }
121
+
122
+ return (
123
+ <div className="flex flex-col h-full bg-surface-0">
124
+ {/* Header */}
125
+ <div className="flex-shrink-0 h-10 flex items-center px-4 border-b border-border bg-surface-3">
126
+ <span className="text-xs font-semibold text-text-1 font-sans">Iterate</span>
127
+ </div>
128
+
129
+ {/* Info banner */}
130
+ <div className="flex-shrink-0 px-4 py-2 bg-accent/5 border-b border-accent/10">
131
+ <p className="text-2xs text-accent font-sans">
132
+ Iterating on <span className="font-semibold">{previewState.teamId || 'project'}</span> — changes auto-refresh via hot reload
133
+ </p>
134
+ </div>
135
+
136
+ {/* Messages */}
137
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
138
+ {previewChat.length === 0 && (
139
+ <div className="flex items-center justify-center h-full">
140
+ <p className="text-sm text-text-3 font-sans text-center">
141
+ Describe changes and they'll be routed to the team planner
142
+ </p>
143
+ </div>
144
+ )}
145
+ {previewChat.map((msg, i) => (
146
+ <PreviewChatMessage key={i} msg={msg} />
147
+ ))}
148
+ {previewIterating && (
149
+ <div className="max-w-[85%] animate-chat-fade-in">
150
+ <div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle px-4 py-3">
151
+ <div className="flex items-center gap-2">
152
+ <Loader2 size={14} className="text-accent animate-spin" />
153
+ <span className="text-xs text-text-3 font-sans">Routing to planner...</span>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ )}
158
+ </div>
159
+
160
+ {/* Input */}
161
+ <div className="flex-shrink-0 px-3 py-3 border-t border-border">
162
+ <div className="flex items-end gap-2 rounded-2xl bg-surface-1/80 border border-accent/8 px-3 py-2">
163
+ <textarea
164
+ ref={textareaRef}
165
+ value={input}
166
+ onChange={(e) => setInput(e.target.value)}
167
+ onKeyDown={onKeyDown}
168
+ placeholder="Describe changes..."
169
+ rows={1}
170
+ style={{ minHeight: '36px' }}
171
+ className="flex-1 resize-none bg-transparent text-sm text-text-0 font-sans placeholder:text-text-4 focus:outline-none py-1.5"
172
+ />
173
+ <button
174
+ onClick={handleSend}
175
+ disabled={!input.trim() || previewIterating}
176
+ className={cn(
177
+ 'w-9 h-9 flex items-center justify-center rounded-xl transition-all cursor-pointer flex-shrink-0',
178
+ 'disabled:opacity-20 disabled:cursor-not-allowed',
179
+ input.trim() && !previewIterating
180
+ ? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
181
+ : 'bg-surface-4 text-text-4',
182
+ )}
183
+ >
184
+ <Send size={16} />
185
+ </button>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
191
+
192
+ function EmptyPreview() {
193
+ return (
194
+ <div className="flex-1 flex items-center justify-center bg-surface-0">
195
+ <div className="text-center space-y-3">
196
+ <MonitorX size={40} className="mx-auto text-text-4" />
197
+ <h2 className="text-lg font-semibold text-text-1 font-sans">No preview active</h2>
198
+ <p className="text-sm text-text-3 font-sans max-w-xs">
199
+ Build a project with a planner team to see it here.
200
+ </p>
201
+ </div>
202
+ </div>
203
+ );
204
+ }
205
+
206
+ const DEVICE_WIDTHS = {
207
+ desktop: '100%',
208
+ tablet: '768px',
209
+ mobile: '375px',
210
+ };
211
+
212
+ export function PreviewWorkspace() {
213
+ const previewState = useGrooveStore((s) => s.previewState);
214
+ const iframeRef = useRef(null);
215
+ const [iframeKey, setIframeKey] = useState(0);
216
+
217
+ const handleRefresh = useCallback(() => {
218
+ setIframeKey((k) => k + 1);
219
+ }, []);
220
+
221
+ if (!previewState.url) {
222
+ return <EmptyPreview />;
223
+ }
224
+
225
+ const iframeSrc = previewState.teamId
226
+ ? `/api/preview/${previewState.teamId}/proxy/`
227
+ : previewState.url;
228
+
229
+ const deviceWidth = DEVICE_WIDTHS[previewState.deviceSize] || '100%';
230
+ const isFullWidth = previewState.deviceSize === 'desktop';
231
+
232
+ return (
233
+ <div className="flex flex-col h-full bg-surface-0 md:flex-row">
234
+ {/* Left pane: iframe */}
235
+ <div className="flex flex-col flex-[3] min-w-0 min-h-0">
236
+ <PreviewToolbar onRefresh={handleRefresh} />
237
+ <div className="flex-1 relative overflow-hidden bg-surface-1">
238
+ {previewState.screenshotMode && (
239
+ <ScreenshotOverlay iframeRef={iframeRef} />
240
+ )}
241
+ <div className={cn(
242
+ 'h-full transition-all duration-200',
243
+ isFullWidth ? 'w-full' : 'mx-auto',
244
+ )} style={isFullWidth ? undefined : { width: deviceWidth, maxWidth: '100%' }}>
245
+ <iframe
246
+ ref={iframeRef}
247
+ key={iframeKey}
248
+ src={iframeSrc}
249
+ title="Preview"
250
+ className="w-full h-full border-0 bg-white"
251
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
252
+ />
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ {/* Right pane: chat */}
258
+ <div className="flex-[2] min-w-[280px] max-w-[480px] border-l border-border md:max-w-none md:flex-[2]">
259
+ <PreviewChat />
260
+ </div>
261
+ </div>
262
+ );
263
+ }
@@ -0,0 +1,203 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useRef, useCallback, useEffect } from 'react';
3
+ import { Send, X } from 'lucide-react';
4
+ import { useGrooveStore } from '../../stores/groove';
5
+
6
+ export function ScreenshotOverlay({ iframeRef }) {
7
+ const toggleScreenshotMode = useGrooveStore((s) => s.toggleScreenshotMode);
8
+ const iteratePreview = useGrooveStore((s) => s.iteratePreview);
9
+
10
+ const overlayRef = useRef(null);
11
+ const [dragging, setDragging] = useState(false);
12
+ const [start, setStart] = useState(null);
13
+ const [end, setEnd] = useState(null);
14
+ const [captured, setCaptured] = useState(null); // { base64, rect }
15
+ const [comment, setComment] = useState('');
16
+
17
+ const handleMouseDown = useCallback((e) => {
18
+ if (captured) return;
19
+ const rect = overlayRef.current.getBoundingClientRect();
20
+ setStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
21
+ setEnd(null);
22
+ setDragging(true);
23
+ }, [captured]);
24
+
25
+ const handleMouseMove = useCallback((e) => {
26
+ if (!dragging || captured) return;
27
+ const rect = overlayRef.current.getBoundingClientRect();
28
+ setEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
29
+ }, [dragging, captured]);
30
+
31
+ const handleMouseUp = useCallback(() => {
32
+ if (!dragging || !start || !end) {
33
+ setDragging(false);
34
+ return;
35
+ }
36
+ setDragging(false);
37
+
38
+ const selRect = {
39
+ x: Math.min(start.x, end.x),
40
+ y: Math.min(start.y, end.y),
41
+ w: Math.abs(end.x - start.x),
42
+ h: Math.abs(end.y - start.y),
43
+ };
44
+
45
+ if (selRect.w < 10 || selRect.h < 10) {
46
+ setStart(null);
47
+ setEnd(null);
48
+ return;
49
+ }
50
+
51
+ try {
52
+ const iframe = iframeRef.current;
53
+ if (!iframe) { setStart(null); setEnd(null); return; }
54
+
55
+ const canvas = document.createElement('canvas');
56
+ const dpr = window.devicePixelRatio || 1;
57
+ canvas.width = selRect.w * dpr;
58
+ canvas.height = selRect.h * dpr;
59
+ const ctx = canvas.getContext('2d');
60
+ ctx.scale(dpr, dpr);
61
+
62
+ // Same-origin proxy iframe — serialize DOM to SVG foreignObject for capture
63
+ const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
64
+ if (iframeDoc) {
65
+ const serializer = new XMLSerializer();
66
+ const html = serializer.serializeToString(iframeDoc.documentElement);
67
+ const iframeRect = iframe.getBoundingClientRect();
68
+ const overlayRect = overlayRef.current.getBoundingClientRect();
69
+ const offsetX = selRect.x - (iframeRect.left - overlayRect.left);
70
+ const offsetY = selRect.y - (iframeRect.top - overlayRect.top);
71
+
72
+ const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="${selRect.w}" height="${selRect.h}">
73
+ <foreignObject x="${-offsetX}" y="${-offsetY}" width="${iframeRect.width}" height="${iframeRect.height}">
74
+ ${html}
75
+ </foreignObject>
76
+ </svg>`;
77
+ const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
78
+ const url = URL.createObjectURL(blob);
79
+ const img = new Image();
80
+ img.onload = () => {
81
+ ctx.drawImage(img, 0, 0, selRect.w, selRect.h);
82
+ URL.revokeObjectURL(url);
83
+ setCaptured({ base64: canvas.toDataURL('image/png'), rect: selRect });
84
+ };
85
+ img.onerror = () => {
86
+ URL.revokeObjectURL(url);
87
+ // Fallback: region placeholder
88
+ ctx.fillStyle = '#1e2127';
89
+ ctx.fillRect(0, 0, selRect.w, selRect.h);
90
+ ctx.fillStyle = '#6e7681';
91
+ ctx.font = '12px Inter, sans-serif';
92
+ ctx.textAlign = 'center';
93
+ ctx.fillText(`Region ${Math.round(selRect.w)}×${Math.round(selRect.h)}`, selRect.w / 2, selRect.h / 2);
94
+ setCaptured({ base64: canvas.toDataURL('image/png'), rect: selRect });
95
+ };
96
+ img.src = url;
97
+ } else {
98
+ ctx.fillStyle = '#1e2127';
99
+ ctx.fillRect(0, 0, selRect.w, selRect.h);
100
+ ctx.fillStyle = '#6e7681';
101
+ ctx.font = '12px Inter, sans-serif';
102
+ ctx.textAlign = 'center';
103
+ ctx.fillText(`Region ${Math.round(selRect.w)}×${Math.round(selRect.h)}`, selRect.w / 2, selRect.h / 2);
104
+ setCaptured({ base64: canvas.toDataURL('image/png'), rect: selRect });
105
+ }
106
+ } catch {
107
+ setStart(null);
108
+ setEnd(null);
109
+ }
110
+ }, [dragging, start, end, iframeRef]);
111
+
112
+ useEffect(() => {
113
+ function onKeyDown(e) {
114
+ if (e.key === 'Escape') toggleScreenshotMode();
115
+ }
116
+ window.addEventListener('keydown', onKeyDown);
117
+ return () => window.removeEventListener('keydown', onKeyDown);
118
+ }, [toggleScreenshotMode]);
119
+
120
+ function handleSubmit() {
121
+ if (!captured) return;
122
+ iteratePreview(comment || 'See screenshot', captured.base64);
123
+ toggleScreenshotMode();
124
+ }
125
+
126
+ const selBox = start && end ? {
127
+ left: Math.min(start.x, end.x),
128
+ top: Math.min(start.y, end.y),
129
+ width: Math.abs(end.x - start.x),
130
+ height: Math.abs(end.y - start.y),
131
+ } : null;
132
+
133
+ return (
134
+ <div
135
+ ref={overlayRef}
136
+ className="absolute inset-0 z-30"
137
+ style={{ cursor: captured ? 'default' : 'crosshair' }}
138
+ onMouseDown={handleMouseDown}
139
+ onMouseMove={handleMouseMove}
140
+ onMouseUp={handleMouseUp}
141
+ >
142
+ {/* Tinted overlay */}
143
+ <div className="absolute inset-0 bg-info/10 pointer-events-none" />
144
+
145
+ {/* Selection rectangle */}
146
+ {selBox && !captured && (
147
+ <div
148
+ className="absolute border-2 border-dashed border-accent bg-accent/5 pointer-events-none"
149
+ style={selBox}
150
+ />
151
+ )}
152
+
153
+ {/* Capture popover */}
154
+ {captured && (
155
+ <div
156
+ className="absolute z-40 w-72 bg-surface-2 border border-border rounded-lg shadow-2xl animate-chat-fade-in"
157
+ style={{
158
+ left: Math.min(captured.rect.x, overlayRef.current?.clientWidth - 300 || 0),
159
+ top: captured.rect.y + captured.rect.h + 8,
160
+ }}
161
+ >
162
+ <div className="p-3 border-b border-border-subtle">
163
+ <img
164
+ src={captured.base64}
165
+ alt="Screenshot"
166
+ className="w-full h-auto rounded border border-border-subtle max-h-32 object-contain bg-surface-0"
167
+ />
168
+ </div>
169
+ <div className="p-3 flex items-center gap-2">
170
+ <input
171
+ type="text"
172
+ value={comment}
173
+ onChange={(e) => setComment(e.target.value)}
174
+ onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
175
+ placeholder="Describe what to change..."
176
+ className="flex-1 h-8 px-3 rounded-md bg-surface-1 border border-border-subtle text-sm text-text-0 font-sans placeholder:text-text-4 focus:outline-none focus:border-accent/40"
177
+ autoFocus
178
+ />
179
+ <button
180
+ onClick={handleSubmit}
181
+ className="w-8 h-8 flex items-center justify-center rounded-md bg-accent/15 text-accent hover:bg-accent/25 transition-colors cursor-pointer"
182
+ >
183
+ <Send size={14} />
184
+ </button>
185
+ <button
186
+ onClick={() => toggleScreenshotMode()}
187
+ className="w-8 h-8 flex items-center justify-center rounded-md text-text-3 hover:text-text-1 hover:bg-surface-4 transition-colors cursor-pointer"
188
+ >
189
+ <X size={14} />
190
+ </button>
191
+ </div>
192
+ </div>
193
+ )}
194
+
195
+ {/* Instructions hint */}
196
+ {!captured && !dragging && (
197
+ <div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-surface-0/90 border border-border-subtle text-2xs text-text-2 font-sans pointer-events-none">
198
+ Click and drag to select a region &middot; Esc to cancel
199
+ </div>
200
+ )}
201
+ </div>
202
+ );
203
+ }
@@ -64,11 +64,15 @@ function ToastItem({ toast }) {
64
64
  <p className="text-xs text-text-3 font-sans mt-0.5">{toast.detail}</p>
65
65
  )}
66
66
  </div>
67
- {toast.action?.url && (
67
+ {(toast.action?.url || toast.action?.onClick) && (
68
68
  <button
69
69
  onClick={(e) => {
70
70
  e.stopPropagation();
71
- try { window.open(toast.action.url, '_blank', 'noopener'); } catch {}
71
+ if (toast.action.onClick) {
72
+ toast.action.onClick();
73
+ } else if (toast.action.url) {
74
+ try { window.open(toast.action.url, '_blank', 'noopener'); } catch {}
75
+ }
72
76
  removeToast(toast.id);
73
77
  }}
74
78
  className="text-xs font-medium text-accent hover:text-accent-hover bg-surface-5 hover:bg-surface-6 px-3 py-1.5 rounded transition-colors cursor-pointer flex-shrink-0 whitespace-nowrap"