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.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +256 -4
- package/node_modules/@groove-dev/daemon/src/conversations.js +16 -0
- package/node_modules/@groove-dev/daemon/src/index.js +41 -1
- package/node_modules/@groove-dev/daemon/src/preview.js +32 -2
- package/node_modules/@groove-dev/daemon/src/process.js +9 -1
- package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +38 -0
- package/node_modules/@groove-dev/daemon/src/providers/grok.js +156 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +5 -1
- package/node_modules/@groove-dev/daemon/src/providers/nano-banana.js +103 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbmPDhuW.js +8616 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.css +41 -0
- package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +16 -5
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +49 -11
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +144 -24
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -2
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +105 -52
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +5 -2
- package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +215 -88
- package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +109 -0
- package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +278 -0
- package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +237 -0
- package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/stores/groove.js +149 -9
- package/node_modules/@groove-dev/gui/src/views/preview.jsx +6 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +199 -114
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +256 -4
- package/packages/daemon/src/conversations.js +16 -0
- package/packages/daemon/src/index.js +41 -1
- package/packages/daemon/src/preview.js +32 -2
- package/packages/daemon/src/process.js +9 -1
- package/packages/daemon/src/providers/base.js +4 -0
- package/packages/daemon/src/providers/codex.js +38 -0
- package/packages/daemon/src/providers/grok.js +156 -0
- package/packages/daemon/src/providers/index.js +5 -1
- package/packages/daemon/src/providers/nano-banana.js +103 -0
- package/packages/gui/dist/assets/index-BbmPDhuW.js +8616 -0
- package/packages/gui/dist/assets/index-kbR5tOHu.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +41 -0
- package/packages/gui/src/app.jsx +2 -0
- package/packages/gui/src/components/chat/chat-header.jsx +16 -5
- package/packages/gui/src/components/chat/chat-input.jsx +49 -11
- package/packages/gui/src/components/chat/chat-messages.jsx +144 -24
- package/packages/gui/src/components/chat/chat-view.jsx +26 -2
- package/packages/gui/src/components/chat/model-picker.jsx +105 -52
- package/packages/gui/src/components/layout/activity-bar.jsx +5 -2
- package/packages/gui/src/components/layout/welcome-splash.jsx +215 -88
- package/packages/gui/src/components/preview/preview-toolbar.jsx +109 -0
- package/packages/gui/src/components/preview/preview-workspace.jsx +278 -0
- package/packages/gui/src/components/preview/screenshot-overlay.jsx +237 -0
- package/packages/gui/src/components/ui/toast.jsx +6 -2
- package/packages/gui/src/stores/groove.js +149 -9
- package/packages/gui/src/views/preview.jsx +6 -0
- package/packages/gui/src/views/settings.jsx +199 -114
- package/welcome.png +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DFP3r2yE.js +0 -8615
- package/node_modules/@groove-dev/gui/dist/assets/index-QR7lyguO.css +0 -1
- package/packages/gui/dist/assets/index-DFP3r2yE.js +0 -8615
- package/packages/gui/dist/assets/index-QR7lyguO.css +0 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
3
|
+
import { Send, Paperclip, MonitorX, Loader2, MessageCircle, Camera, RefreshCw } 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
|
+
<div className="max-w-xs w-full px-5 py-5 bg-surface-1 border border-border-subtle rounded-xl text-center">
|
|
141
|
+
<MessageCircle size={24} className="mx-auto text-accent mb-3" />
|
|
142
|
+
<h3 className="text-sm font-semibold text-text-0 font-sans mb-3">Preview is live!</h3>
|
|
143
|
+
<ul className="text-left space-y-2 text-2xs text-text-2 font-sans">
|
|
144
|
+
<li className="flex gap-2">
|
|
145
|
+
<Send size={11} className="text-text-3 mt-0.5 flex-shrink-0" />
|
|
146
|
+
<span>Type a message to request changes — your feedback goes to the team planner who routes it to the right agent</span>
|
|
147
|
+
</li>
|
|
148
|
+
<li className="flex gap-2">
|
|
149
|
+
<Camera size={11} className="text-text-3 mt-0.5 flex-shrink-0" />
|
|
150
|
+
<span>Use the camera icon to screenshot a specific area and annotate it</span>
|
|
151
|
+
</li>
|
|
152
|
+
<li className="flex gap-2">
|
|
153
|
+
<RefreshCw size={11} className="text-text-3 mt-0.5 flex-shrink-0" />
|
|
154
|
+
<span>Changes auto-refresh via hot module reload</span>
|
|
155
|
+
</li>
|
|
156
|
+
</ul>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
{previewChat.map((msg, i) => (
|
|
161
|
+
<PreviewChatMessage key={i} msg={msg} />
|
|
162
|
+
))}
|
|
163
|
+
{previewIterating && (
|
|
164
|
+
<div className="max-w-[85%] animate-chat-fade-in">
|
|
165
|
+
<div className="rounded-2xl rounded-tl-md bg-surface-1/80 border border-border-subtle px-4 py-3">
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
167
|
+
<Loader2 size={14} className="text-accent animate-spin" />
|
|
168
|
+
<span className="text-xs text-text-3 font-sans">Routing to planner...</span>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Input */}
|
|
176
|
+
<div className="flex-shrink-0 px-3 py-3 border-t border-border">
|
|
177
|
+
<div className="flex items-end gap-2 rounded-2xl bg-surface-1/80 border border-accent/8 px-3 py-2">
|
|
178
|
+
<textarea
|
|
179
|
+
ref={textareaRef}
|
|
180
|
+
value={input}
|
|
181
|
+
onChange={(e) => setInput(e.target.value)}
|
|
182
|
+
onKeyDown={onKeyDown}
|
|
183
|
+
placeholder="Describe changes..."
|
|
184
|
+
rows={1}
|
|
185
|
+
style={{ minHeight: '36px' }}
|
|
186
|
+
className="flex-1 resize-none bg-transparent text-sm text-text-0 font-sans placeholder:text-text-4 focus:outline-none py-1.5"
|
|
187
|
+
/>
|
|
188
|
+
<button
|
|
189
|
+
onClick={handleSend}
|
|
190
|
+
disabled={!input.trim() || previewIterating}
|
|
191
|
+
className={cn(
|
|
192
|
+
'w-9 h-9 flex items-center justify-center rounded-xl transition-all cursor-pointer flex-shrink-0',
|
|
193
|
+
'disabled:opacity-20 disabled:cursor-not-allowed',
|
|
194
|
+
input.trim() && !previewIterating
|
|
195
|
+
? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
|
|
196
|
+
: 'bg-surface-4 text-text-4',
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
<Send size={16} />
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function EmptyPreview() {
|
|
208
|
+
return (
|
|
209
|
+
<div className="flex-1 flex items-center justify-center bg-surface-0">
|
|
210
|
+
<div className="text-center space-y-3">
|
|
211
|
+
<MonitorX size={40} className="mx-auto text-text-4" />
|
|
212
|
+
<h2 className="text-lg font-semibold text-text-1 font-sans">No preview active</h2>
|
|
213
|
+
<p className="text-sm text-text-3 font-sans max-w-xs">
|
|
214
|
+
Build a project with a planner team to see it here.
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const DEVICE_WIDTHS = {
|
|
222
|
+
desktop: '100%',
|
|
223
|
+
tablet: '768px',
|
|
224
|
+
mobile: '375px',
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export function PreviewWorkspace() {
|
|
228
|
+
const previewState = useGrooveStore((s) => s.previewState);
|
|
229
|
+
const iframeRef = useRef(null);
|
|
230
|
+
const [iframeKey, setIframeKey] = useState(0);
|
|
231
|
+
|
|
232
|
+
const handleRefresh = useCallback(() => {
|
|
233
|
+
setIframeKey((k) => k + 1);
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
if (!previewState.url) {
|
|
237
|
+
return <EmptyPreview />;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const iframeSrc = previewState.teamId
|
|
241
|
+
? `/api/preview/${previewState.teamId}/proxy/`
|
|
242
|
+
: previewState.url;
|
|
243
|
+
|
|
244
|
+
const deviceWidth = DEVICE_WIDTHS[previewState.deviceSize] || '100%';
|
|
245
|
+
const isFullWidth = previewState.deviceSize === 'desktop';
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div className="flex flex-col h-full bg-surface-0 md:flex-row">
|
|
249
|
+
{/* Left pane: iframe */}
|
|
250
|
+
<div className="flex flex-col flex-[3] min-w-0 min-h-0">
|
|
251
|
+
<PreviewToolbar onRefresh={handleRefresh} />
|
|
252
|
+
<div className="flex-1 relative overflow-hidden bg-surface-1">
|
|
253
|
+
{previewState.screenshotMode && (
|
|
254
|
+
<ScreenshotOverlay iframeRef={iframeRef} />
|
|
255
|
+
)}
|
|
256
|
+
<div className={cn(
|
|
257
|
+
'h-full transition-all duration-200',
|
|
258
|
+
isFullWidth ? 'w-full' : 'mx-auto',
|
|
259
|
+
)} style={isFullWidth ? undefined : { width: deviceWidth, maxWidth: '100%' }}>
|
|
260
|
+
<iframe
|
|
261
|
+
ref={iframeRef}
|
|
262
|
+
key={iframeKey}
|
|
263
|
+
src={iframeSrc}
|
|
264
|
+
title="Preview"
|
|
265
|
+
className="w-full h-full border-0 bg-white"
|
|
266
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Right pane: chat */}
|
|
273
|
+
<div className="flex-[2] min-w-[280px] max-w-[480px] border-l border-border md:max-w-none md:flex-[2]">
|
|
274
|
+
<PreviewChat />
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
3
|
+
import { Send, X, Loader2 } 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, loading }
|
|
15
|
+
const [comment, setComment] = useState('');
|
|
16
|
+
const [flashRect, setFlashRect] = useState(null);
|
|
17
|
+
|
|
18
|
+
const handleMouseDown = useCallback((e) => {
|
|
19
|
+
if (captured) return;
|
|
20
|
+
const rect = overlayRef.current.getBoundingClientRect();
|
|
21
|
+
setStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
22
|
+
setEnd(null);
|
|
23
|
+
setDragging(true);
|
|
24
|
+
}, [captured]);
|
|
25
|
+
|
|
26
|
+
const handleMouseMove = useCallback((e) => {
|
|
27
|
+
if (!dragging || captured) return;
|
|
28
|
+
const rect = overlayRef.current.getBoundingClientRect();
|
|
29
|
+
setEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
30
|
+
}, [dragging, captured]);
|
|
31
|
+
|
|
32
|
+
const handleMouseUp = useCallback(() => {
|
|
33
|
+
if (!dragging || !start || !end) {
|
|
34
|
+
setDragging(false);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setDragging(false);
|
|
38
|
+
|
|
39
|
+
const selRect = {
|
|
40
|
+
x: Math.min(start.x, end.x),
|
|
41
|
+
y: Math.min(start.y, end.y),
|
|
42
|
+
w: Math.abs(end.x - start.x),
|
|
43
|
+
h: Math.abs(end.y - start.y),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (selRect.w < 10 || selRect.h < 10) {
|
|
47
|
+
setStart(null);
|
|
48
|
+
setEnd(null);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setCaptured({ base64: null, rect: selRect, loading: true });
|
|
53
|
+
|
|
54
|
+
function finishCapture(base64) {
|
|
55
|
+
setCaptured({ base64, rect: selRect, loading: false });
|
|
56
|
+
setFlashRect(selRect);
|
|
57
|
+
setTimeout(() => setFlashRect(null), 600);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function drawPlaceholder() {
|
|
61
|
+
const canvas = document.createElement('canvas');
|
|
62
|
+
const dpr = window.devicePixelRatio || 1;
|
|
63
|
+
canvas.width = selRect.w * dpr;
|
|
64
|
+
canvas.height = selRect.h * dpr;
|
|
65
|
+
const ctx = canvas.getContext('2d');
|
|
66
|
+
ctx.scale(dpr, dpr);
|
|
67
|
+
ctx.fillStyle = '#1e2127';
|
|
68
|
+
ctx.fillRect(0, 0, selRect.w, selRect.h);
|
|
69
|
+
ctx.strokeStyle = '#3e4451';
|
|
70
|
+
ctx.lineWidth = 1;
|
|
71
|
+
ctx.strokeRect(4, 4, selRect.w - 8, selRect.h - 8);
|
|
72
|
+
ctx.fillStyle = '#6e7681';
|
|
73
|
+
ctx.font = '12px Inter, sans-serif';
|
|
74
|
+
ctx.textAlign = 'center';
|
|
75
|
+
ctx.textBaseline = 'middle';
|
|
76
|
+
ctx.fillText(`${Math.round(selRect.w)} × ${Math.round(selRect.h)}`, selRect.w / 2, selRect.h / 2);
|
|
77
|
+
return canvas.toDataURL('image/png');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const iframe = iframeRef.current;
|
|
82
|
+
if (!iframe) { finishCapture(drawPlaceholder()); return; }
|
|
83
|
+
|
|
84
|
+
const canvas = document.createElement('canvas');
|
|
85
|
+
const dpr = window.devicePixelRatio || 1;
|
|
86
|
+
canvas.width = selRect.w * dpr;
|
|
87
|
+
canvas.height = selRect.h * dpr;
|
|
88
|
+
const ctx = canvas.getContext('2d');
|
|
89
|
+
ctx.scale(dpr, dpr);
|
|
90
|
+
|
|
91
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
92
|
+
const overlayRect = overlayRef.current.getBoundingClientRect();
|
|
93
|
+
const offsetX = selRect.x - (iframeRect.left - overlayRect.left);
|
|
94
|
+
const offsetY = selRect.y - (iframeRect.top - overlayRect.top);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
ctx.drawImage(iframe, -offsetX * dpr, -offsetY * dpr, iframeRect.width * dpr, iframeRect.height * dpr, 0, 0, selRect.w, selRect.h);
|
|
98
|
+
const testPixel = ctx.getImageData(0, 0, 1, 1).data;
|
|
99
|
+
if (testPixel[0] === 0 && testPixel[1] === 0 && testPixel[2] === 0 && testPixel[3] === 0) {
|
|
100
|
+
throw new Error('blank');
|
|
101
|
+
}
|
|
102
|
+
finishCapture(canvas.toDataURL('image/png'));
|
|
103
|
+
} catch {
|
|
104
|
+
finishCapture(drawPlaceholder());
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
setStart(null);
|
|
108
|
+
setEnd(null);
|
|
109
|
+
setCaptured(null);
|
|
110
|
+
}
|
|
111
|
+
}, [dragging, start, end, iframeRef]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
function onKeyDown(e) {
|
|
115
|
+
if (e.key === 'Escape') toggleScreenshotMode();
|
|
116
|
+
}
|
|
117
|
+
window.addEventListener('keydown', onKeyDown);
|
|
118
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
119
|
+
}, [toggleScreenshotMode]);
|
|
120
|
+
|
|
121
|
+
function handleSubmit() {
|
|
122
|
+
if (!captured || captured.loading) return;
|
|
123
|
+
iteratePreview(comment || 'See screenshot', captured.base64);
|
|
124
|
+
toggleScreenshotMode();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const selBox = start && end ? {
|
|
128
|
+
left: Math.min(start.x, end.x),
|
|
129
|
+
top: Math.min(start.y, end.y),
|
|
130
|
+
width: Math.abs(end.x - start.x),
|
|
131
|
+
height: Math.abs(end.y - start.y),
|
|
132
|
+
} : null;
|
|
133
|
+
|
|
134
|
+
const popoverPosition = useCallback((rect) => {
|
|
135
|
+
if (!rect || !overlayRef.current) return {};
|
|
136
|
+
const overlayH = overlayRef.current.clientHeight;
|
|
137
|
+
const overlayW = overlayRef.current.clientWidth;
|
|
138
|
+
const spaceBelow = overlayH - (rect.y + rect.h + 8);
|
|
139
|
+
const popoverH = 200;
|
|
140
|
+
const placeAbove = spaceBelow < popoverH && rect.y > popoverH;
|
|
141
|
+
return {
|
|
142
|
+
left: Math.max(8, Math.min(rect.x, overlayW - 300)),
|
|
143
|
+
top: placeAbove ? rect.y - popoverH - 8 : rect.y + rect.h + 8,
|
|
144
|
+
};
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
ref={overlayRef}
|
|
150
|
+
className="absolute inset-0 z-30"
|
|
151
|
+
style={{ cursor: captured ? 'default' : 'crosshair' }}
|
|
152
|
+
onMouseDown={handleMouseDown}
|
|
153
|
+
onMouseMove={handleMouseMove}
|
|
154
|
+
onMouseUp={handleMouseUp}
|
|
155
|
+
>
|
|
156
|
+
{/* Tinted overlay */}
|
|
157
|
+
<div className="absolute inset-0 bg-info/10 pointer-events-none" />
|
|
158
|
+
|
|
159
|
+
{/* Selection rectangle */}
|
|
160
|
+
{selBox && !captured && (
|
|
161
|
+
<div
|
|
162
|
+
className="absolute border-2 border-dashed border-accent bg-accent/5 pointer-events-none"
|
|
163
|
+
style={selBox}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Capture flash */}
|
|
168
|
+
{flashRect && (
|
|
169
|
+
<div
|
|
170
|
+
className="absolute pointer-events-none animate-capture-flash rounded"
|
|
171
|
+
style={{ left: flashRect.x, top: flashRect.y, width: flashRect.w, height: flashRect.h }}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{/* Captured selection outline */}
|
|
176
|
+
{captured && (
|
|
177
|
+
<div
|
|
178
|
+
className="absolute border-2 border-accent rounded pointer-events-none"
|
|
179
|
+
style={{ left: captured.rect.x, top: captured.rect.y, width: captured.rect.w, height: captured.rect.h }}
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{/* Capture popover */}
|
|
184
|
+
{captured && (
|
|
185
|
+
<div
|
|
186
|
+
className="absolute z-40 w-72 bg-surface-2 border border-border rounded-lg shadow-2xl animate-chat-fade-in"
|
|
187
|
+
style={popoverPosition(captured.rect)}
|
|
188
|
+
>
|
|
189
|
+
<div className="p-3 border-b border-border-subtle">
|
|
190
|
+
{captured.loading ? (
|
|
191
|
+
<div className="w-full h-24 rounded border border-border-subtle bg-surface-0 flex items-center justify-center">
|
|
192
|
+
<Loader2 size={20} className="text-accent animate-spin" />
|
|
193
|
+
</div>
|
|
194
|
+
) : (
|
|
195
|
+
<img
|
|
196
|
+
src={captured.base64}
|
|
197
|
+
alt="Screenshot"
|
|
198
|
+
className="w-full h-auto rounded border border-border-subtle max-h-32 object-contain bg-surface-0"
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
<div className="p-3 flex items-center gap-2">
|
|
203
|
+
<input
|
|
204
|
+
type="text"
|
|
205
|
+
value={comment}
|
|
206
|
+
onChange={(e) => setComment(e.target.value)}
|
|
207
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
|
208
|
+
placeholder="Describe what to change..."
|
|
209
|
+
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"
|
|
210
|
+
autoFocus
|
|
211
|
+
/>
|
|
212
|
+
<button
|
|
213
|
+
onClick={handleSubmit}
|
|
214
|
+
disabled={captured.loading}
|
|
215
|
+
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 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
216
|
+
>
|
|
217
|
+
<Send size={14} />
|
|
218
|
+
</button>
|
|
219
|
+
<button
|
|
220
|
+
onClick={() => toggleScreenshotMode()}
|
|
221
|
+
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"
|
|
222
|
+
>
|
|
223
|
+
<X size={14} />
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{/* Instructions hint */}
|
|
230
|
+
{!captured && !dragging && (
|
|
231
|
+
<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">
|
|
232
|
+
Click and drag to select a region · Esc to cancel
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -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
|
-
|
|
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"
|