sonance-brand-mcp 1.3.14 → 1.3.16

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.
@@ -0,0 +1,230 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { X, Zap, Loader2, Check, Undo, ChevronDown, ChevronRight, FileCode, AlertTriangle, RefreshCw, Info } from "lucide-react";
5
+ import { cn } from "../../../lib/utils";
6
+ import { ApplyFirstSession, ApplyFirstStatus, VisionFileModification } from "../types";
7
+
8
+ export interface ApplyFirstPreviewProps {
9
+ session: ApplyFirstSession;
10
+ status: ApplyFirstStatus;
11
+ onAccept: () => void;
12
+ onRevert: () => void;
13
+ }
14
+
15
+ function FileModificationCard({
16
+ modification,
17
+ isExpanded,
18
+ onToggle,
19
+ }: {
20
+ modification: VisionFileModification;
21
+ isExpanded: boolean;
22
+ onToggle: () => void;
23
+ }) {
24
+ return (
25
+ <div className="border border-gray-200 rounded bg-white overflow-hidden">
26
+ {/* File Header */}
27
+ <button
28
+ onClick={onToggle}
29
+ className="w-full flex items-center gap-2 p-2 hover:bg-gray-50 transition-colors text-left"
30
+ >
31
+ {isExpanded ? (
32
+ <ChevronDown className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
33
+ ) : (
34
+ <ChevronRight className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
35
+ )}
36
+ <FileCode className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
37
+ <span className="text-xs font-mono text-gray-700 truncate flex-1">
38
+ {modification.filePath}
39
+ </span>
40
+ </button>
41
+
42
+ {/* Explanation */}
43
+ <div className="px-2 pb-2">
44
+ <p className="text-[10px] text-gray-500">{modification.explanation}</p>
45
+ </div>
46
+
47
+ {/* Expanded Diff */}
48
+ {isExpanded && (
49
+ <div className="border-t border-gray-100">
50
+ <div className="max-h-60 overflow-auto">
51
+ <pre className="p-2 text-[10px] font-mono whitespace-pre-wrap">
52
+ {modification.diff.split("\n").map((line, i) => (
53
+ <div
54
+ key={i}
55
+ className={cn(
56
+ line.startsWith("+") && !line.startsWith("@@")
57
+ ? "bg-green-50 text-green-700"
58
+ : line.startsWith("-") && !line.startsWith("@@")
59
+ ? "bg-red-50 text-red-700"
60
+ : line.startsWith("@@")
61
+ ? "bg-blue-50 text-blue-600 font-semibold"
62
+ : "text-gray-600"
63
+ )}
64
+ >
65
+ {line || " "}
66
+ </div>
67
+ ))}
68
+ </pre>
69
+ </div>
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ function HMRStatusBadge({ status }: { status: ApplyFirstStatus }) {
77
+ if (status === "waiting-hmr") {
78
+ return (
79
+ <span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">
80
+ <RefreshCw className="h-3 w-3 animate-spin" />
81
+ Refreshing...
82
+ </span>
83
+ );
84
+ }
85
+
86
+ if (status === "reviewing") {
87
+ return (
88
+ <span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700">
89
+ <Check className="h-3 w-3" />
90
+ Changes Live
91
+ </span>
92
+ );
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ export function ApplyFirstPreview({
99
+ session,
100
+ status,
101
+ onAccept,
102
+ onRevert,
103
+ }: ApplyFirstPreviewProps) {
104
+ const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
105
+
106
+ // Expand first file by default
107
+ useEffect(() => {
108
+ if (session.modifications.length > 0) {
109
+ setExpandedFiles(new Set([session.modifications[0].filePath]));
110
+ }
111
+ }, [session]);
112
+
113
+ const toggleFile = (filePath: string) => {
114
+ setExpandedFiles((prev) => {
115
+ const next = new Set(prev);
116
+ if (next.has(filePath)) {
117
+ next.delete(filePath);
118
+ } else {
119
+ next.add(filePath);
120
+ }
121
+ return next;
122
+ });
123
+ };
124
+
125
+ const fileCount = session.modifications.length;
126
+ const isLoading = status === "accepting" || status === "reverting";
127
+
128
+ return (
129
+ <div className="space-y-3 p-3 rounded border border-green-300 bg-green-50">
130
+ {/* Header */}
131
+ <div className="flex items-center justify-between">
132
+ <div className="flex items-center gap-2">
133
+ <Zap className="h-4 w-4 text-green-600" />
134
+ <span className="text-xs font-semibold text-gray-900">
135
+ Changes Applied
136
+ </span>
137
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-200 text-green-700 font-medium">
138
+ {fileCount} file{fileCount !== 1 ? "s" : ""}
139
+ </span>
140
+ </div>
141
+ <HMRStatusBadge status={status} />
142
+ </div>
143
+
144
+ {/* Info Banner */}
145
+ <div className="flex items-start gap-2 p-2 rounded bg-blue-50 border border-blue-200">
146
+ <Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
147
+ <span className="text-xs text-blue-700">
148
+ <strong>Changes are live!</strong> Scroll around to see the actual result.
149
+ Your original files are safely backed up.
150
+ </span>
151
+ </div>
152
+
153
+ {/* File Modifications List */}
154
+ <div className="space-y-2 max-h-80 overflow-y-auto">
155
+ {session.modifications.map((mod) => (
156
+ <FileModificationCard
157
+ key={mod.filePath}
158
+ modification={mod}
159
+ isExpanded={expandedFiles.has(mod.filePath)}
160
+ onToggle={() => toggleFile(mod.filePath)}
161
+ />
162
+ ))}
163
+ </div>
164
+
165
+ {/* Warning about navigation */}
166
+ <div className="flex items-start gap-2 p-2 rounded bg-amber-50 border border-amber-200">
167
+ <AlertTriangle className="h-3.5 w-3.5 text-amber-600 mt-0.5 flex-shrink-0" />
168
+ <span className="text-xs text-amber-700">
169
+ Navigating away will automatically revert changes. Make sure to accept or revert first.
170
+ </span>
171
+ </div>
172
+
173
+ {/* Action Buttons */}
174
+ <div className="flex gap-2">
175
+ {/* Accept Button */}
176
+ <button
177
+ onClick={onAccept}
178
+ disabled={isLoading}
179
+ className={cn(
180
+ "flex-1 flex items-center justify-center gap-2 py-2.5",
181
+ "text-xs font-medium rounded transition-colors",
182
+ "bg-green-600 text-white hover:bg-green-700",
183
+ "disabled:opacity-50 disabled:cursor-not-allowed"
184
+ )}
185
+ >
186
+ {status === "accepting" ? (
187
+ <>
188
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
189
+ Accepting...
190
+ </>
191
+ ) : (
192
+ <>
193
+ <Check className="h-3.5 w-3.5" />
194
+ Keep Changes
195
+ </>
196
+ )}
197
+ </button>
198
+
199
+ {/* Revert Button */}
200
+ <button
201
+ onClick={onRevert}
202
+ disabled={isLoading}
203
+ className={cn(
204
+ "flex-1 flex items-center justify-center gap-2 py-2.5",
205
+ "text-xs font-medium rounded transition-colors",
206
+ "border border-gray-300 text-gray-700 bg-white hover:bg-gray-50",
207
+ "disabled:opacity-50 disabled:cursor-not-allowed"
208
+ )}
209
+ >
210
+ {status === "reverting" ? (
211
+ <>
212
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
213
+ Reverting...
214
+ </>
215
+ ) : (
216
+ <>
217
+ <Undo className="h-3.5 w-3.5" />
218
+ Revert
219
+ </>
220
+ )}
221
+ </button>
222
+ </div>
223
+
224
+ {/* Session Info (for debugging) */}
225
+ <div className="text-[10px] text-gray-400 font-mono">
226
+ Session: {session.sessionId} | Applied: {new Date(session.appliedAt).toLocaleTimeString()}
227
+ </div>
228
+ </div>
229
+ );
230
+ }
@@ -0,0 +1,455 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useCallback, useRef } from "react";
4
+ import { Loader2, Send, Sparkles, Eye } from "lucide-react";
5
+ import { cn } from "../../../lib/utils";
6
+ import { ChatMessage, AIEditResult, PendingEdit, VisionFocusedElement, VisionPendingEdit, ApplyFirstSession } from "../types";
7
+ import html2canvas from "html2canvas-pro";
8
+
9
+ // Variant styles captured from the DOM
10
+ export interface VariantStyles {
11
+ backgroundColor: string;
12
+ color: string;
13
+ borderColor: string;
14
+ borderRadius: string;
15
+ borderWidth: string;
16
+ padding: string;
17
+ fontSize: string;
18
+ fontWeight: string;
19
+ boxShadow: string;
20
+ }
21
+
22
+ export interface ChatInterfaceProps {
23
+ componentType: string;
24
+ componentName: string;
25
+ onEditComplete: (result: AIEditResult) => void;
26
+ onSaveRequest: (edit: PendingEdit) => void;
27
+ pendingEdit: PendingEdit | null;
28
+ onClearPending: () => void;
29
+ // Variant-scoped editing
30
+ editScope?: "component" | "variant";
31
+ variantId?: string | null;
32
+ variantStyles?: VariantStyles | null;
33
+ // Vision mode props
34
+ visionMode?: boolean;
35
+ visionFocusedElements?: VisionFocusedElement[];
36
+ onVisionEditComplete?: (result: VisionPendingEdit) => void;
37
+ // Apply-first mode - NEW: writes files immediately
38
+ onApplyFirstComplete?: (session: ApplyFirstSession) => void;
39
+ }
40
+
41
+ export function ChatInterface({
42
+ componentType,
43
+ componentName,
44
+ onEditComplete,
45
+ onSaveRequest,
46
+ pendingEdit,
47
+ onClearPending,
48
+ editScope = "component",
49
+ variantId,
50
+ variantStyles,
51
+ visionMode = false,
52
+ visionFocusedElements = [],
53
+ onVisionEditComplete,
54
+ onApplyFirstComplete,
55
+ }: ChatInterfaceProps) {
56
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
57
+ const [input, setInput] = useState("");
58
+ const [isProcessing, setIsProcessing] = useState(false);
59
+ const messagesEndRef = useRef<HTMLDivElement>(null);
60
+
61
+ // Scroll to bottom when messages change
62
+ useEffect(() => {
63
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
64
+ }, [messages]);
65
+
66
+ // Dynamically discover component file path via API
67
+ const findComponentFile = useCallback(async (): Promise<string | null> => {
68
+ try {
69
+ const response = await fetch(
70
+ `/api/sonance-find-component?component=${encodeURIComponent(componentType)}`
71
+ );
72
+ const data = await response.json();
73
+
74
+ if (data.found && data.filePath) {
75
+ return data.filePath;
76
+ }
77
+
78
+ console.warn(`Component file not found for: ${componentType}`, data);
79
+ return null;
80
+ } catch (error) {
81
+ console.error("Error finding component file:", error);
82
+ return null;
83
+ }
84
+ }, [componentType]);
85
+
86
+ // Capture screenshot for vision mode
87
+ const captureScreenshot = useCallback(async (): Promise<string | null> => {
88
+ try {
89
+ const canvas = await html2canvas(document.body, {
90
+ ignoreElements: (element) => {
91
+ // Exclude DevTools overlay and vision mode border
92
+ return (
93
+ element.hasAttribute("data-sonance-devtools") ||
94
+ element.hasAttribute("data-vision-mode-border")
95
+ );
96
+ },
97
+ useCORS: true,
98
+ allowTaint: true,
99
+ scale: 1, // Lower scale for smaller file size
100
+ });
101
+
102
+ return canvas.toDataURL("image/png", 0.8);
103
+ } catch (error) {
104
+ console.error("Failed to capture screenshot:", error);
105
+ return null;
106
+ }
107
+ }, []);
108
+
109
+ // Handle vision mode edit request
110
+ const handleVisionEdit = async (prompt: string) => {
111
+ // Use Apply-First mode if callback is provided (new Cursor-style workflow)
112
+ const useApplyFirst = !!onApplyFirstComplete;
113
+
114
+ console.log("[Vision Mode] Starting edit request:", {
115
+ prompt,
116
+ focusedElements: visionFocusedElements.length,
117
+ mode: useApplyFirst ? "apply-first" : "preview-first"
118
+ });
119
+
120
+ const userMessage: ChatMessage = {
121
+ id: `msg-${Date.now()}`,
122
+ role: "user",
123
+ content: prompt,
124
+ timestamp: new Date(),
125
+ };
126
+
127
+ setMessages((prev) => [...prev, userMessage]);
128
+ setInput("");
129
+ setIsProcessing(true);
130
+
131
+ try {
132
+ // Capture screenshot
133
+ console.log("[Vision Mode] Capturing screenshot...");
134
+ const screenshot = await captureScreenshot();
135
+ console.log("[Vision Mode] Screenshot captured:", screenshot ? `${screenshot.length} bytes` : "null");
136
+
137
+ // Choose API endpoint based on mode
138
+ const endpoint = useApplyFirst ? "/api/sonance-vision-apply" : "/api/sonance-vision-edit";
139
+ console.log("[Vision Mode] Sending to API:", endpoint);
140
+
141
+ const response = await fetch(endpoint, {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({
145
+ action: useApplyFirst ? "apply" : "edit",
146
+ screenshot,
147
+ pageRoute: window.location.pathname,
148
+ userPrompt: prompt,
149
+ focusedElements: visionFocusedElements,
150
+ }),
151
+ });
152
+
153
+ const data = await response.json();
154
+ console.log("[Vision Mode] API response:", {
155
+ success: data.success,
156
+ sessionId: data.sessionId,
157
+ modificationsCount: data.modifications?.length || 0,
158
+ hasCss: !!data.aggregatedPreviewCSS,
159
+ error: data.error,
160
+ });
161
+
162
+ const assistantMessage: ChatMessage = {
163
+ id: `msg-${Date.now()}-response`,
164
+ role: "assistant",
165
+ content: data.success
166
+ ? useApplyFirst
167
+ ? data.explanation || "Changes applied! Review and accept or revert."
168
+ : data.explanation || "Vision mode changes ready for preview."
169
+ : data.error || "Failed to generate changes.",
170
+ timestamp: new Date(),
171
+ };
172
+
173
+ setMessages((prev) => [...prev, assistantMessage]);
174
+
175
+ if (data.success && data.modifications) {
176
+ if (useApplyFirst && onApplyFirstComplete) {
177
+ // Apply-First mode: files are already written
178
+ console.log("[Apply-First] Calling onApplyFirstComplete with:", {
179
+ sessionId: data.sessionId,
180
+ modifications: data.modifications.map((m: { filePath: string }) => m.filePath),
181
+ });
182
+ onApplyFirstComplete({
183
+ sessionId: data.sessionId,
184
+ modifications: data.modifications,
185
+ appliedAt: Date.now(),
186
+ status: 'applied',
187
+ backupPaths: data.backupPaths || [],
188
+ });
189
+ } else if (onVisionEditComplete) {
190
+ // Preview-First mode (legacy): just preview CSS
191
+ console.log("[Vision Mode] Calling onVisionEditComplete with:", {
192
+ modifications: data.modifications.map((m: { filePath: string }) => m.filePath),
193
+ cssLength: data.aggregatedPreviewCSS?.length || 0,
194
+ });
195
+ onVisionEditComplete({
196
+ modifications: data.modifications,
197
+ aggregatedPreviewCSS: data.aggregatedPreviewCSS || "",
198
+ explanation: data.explanation || "",
199
+ });
200
+ }
201
+ } else if (!data.success) {
202
+ console.error("[Vision Mode] API returned error:", data.error);
203
+ }
204
+ } catch (error) {
205
+ console.error("[Vision Mode] Request failed:", error);
206
+ const errorMessage: ChatMessage = {
207
+ id: `msg-${Date.now()}-error`,
208
+ role: "assistant",
209
+ content: error instanceof Error ? error.message : "Vision mode error occurred",
210
+ timestamp: new Date(),
211
+ };
212
+ setMessages((prev) => [...prev, errorMessage]);
213
+ } finally {
214
+ setIsProcessing(false);
215
+ }
216
+ };
217
+
218
+ const handleSend = async (prompt: string) => {
219
+ if (!prompt.trim() || isProcessing) return;
220
+
221
+ // Use vision mode handler if vision mode is active
222
+ if (visionMode) {
223
+ return handleVisionEdit(prompt);
224
+ }
225
+
226
+ // If no component is selected, intercept the request
227
+ if (componentType === "all") {
228
+ const userMessage: ChatMessage = {
229
+ id: `msg-${Date.now()}`,
230
+ role: "user",
231
+ content: prompt,
232
+ timestamp: new Date(),
233
+ };
234
+ setMessages((prev) => [...prev, userMessage]);
235
+ setInput("");
236
+
237
+ setTimeout(() => {
238
+ const assistantMessage: ChatMessage = {
239
+ id: `msg-${Date.now()}-response`,
240
+ role: "assistant",
241
+ content: "Please select a component using the cursor icon in the header to edit it.",
242
+ timestamp: new Date(),
243
+ };
244
+ setMessages((prev) => [...prev, assistantMessage]);
245
+ }, 300);
246
+ return;
247
+ }
248
+
249
+ const userMessage: ChatMessage = {
250
+ id: `msg-${Date.now()}`,
251
+ role: "user",
252
+ content: prompt,
253
+ timestamp: new Date(),
254
+ };
255
+
256
+ setMessages((prev) => [...prev, userMessage]);
257
+ setInput("");
258
+ setIsProcessing(true);
259
+
260
+ try {
261
+ // Dynamically find the component file
262
+ const filePath = await findComponentFile();
263
+
264
+ if (!filePath) {
265
+ throw new Error(`Could not locate component file for "${componentType}". The component may not exist in the expected directories.`);
266
+ }
267
+
268
+ // First, fetch the current component source
269
+ const sourceResponse = await fetch(
270
+ `/api/sonance-component-source?file=${encodeURIComponent(filePath)}`
271
+ );
272
+
273
+ if (!sourceResponse.ok) {
274
+ throw new Error(`Could not read component file: ${filePath}`);
275
+ }
276
+
277
+ const sourceData = await sourceResponse.json();
278
+
279
+ // Then, send to AI for editing
280
+ const editResponse = await fetch("/api/sonance-ai-edit", {
281
+ method: "POST",
282
+ headers: { "Content-Type": "application/json" },
283
+ body: JSON.stringify({
284
+ action: "edit",
285
+ componentType,
286
+ filePath,
287
+ currentCode: sourceData.content,
288
+ userRequest: prompt,
289
+ // Variant-scoped editing context
290
+ editScope,
291
+ variantId: editScope === "variant" ? variantId : undefined,
292
+ variantStyles: editScope === "variant" ? variantStyles : undefined,
293
+ }),
294
+ });
295
+
296
+ const editData = await editResponse.json();
297
+
298
+ const assistantMessage: ChatMessage = {
299
+ id: `msg-${Date.now()}-response`,
300
+ role: "assistant",
301
+ content: editData.success
302
+ ? editData.explanation || "Changes ready for preview."
303
+ : editData.error || "Failed to generate changes.",
304
+ timestamp: new Date(),
305
+ editResult: editData,
306
+ };
307
+
308
+ setMessages((prev) => [...prev, assistantMessage]);
309
+
310
+ if (editData.success && editData.modifiedCode) {
311
+ onEditComplete(editData);
312
+ // Set up pending edit for save
313
+ onSaveRequest({
314
+ filePath,
315
+ originalCode: sourceData.content,
316
+ modifiedCode: editData.modifiedCode,
317
+ diff: editData.diff || "",
318
+ explanation: editData.explanation || "",
319
+ // AI-provided CSS for live preview (no parsing needed)
320
+ previewCSS: editData.previewCSS || "",
321
+ });
322
+ }
323
+ } catch (error) {
324
+ const errorMessage: ChatMessage = {
325
+ id: `msg-${Date.now()}-error`,
326
+ role: "assistant",
327
+ content: error instanceof Error ? error.message : "An error occurred",
328
+ timestamp: new Date(),
329
+ editResult: { success: false, error: String(error) },
330
+ };
331
+ setMessages((prev) => [...prev, errorMessage]);
332
+ } finally {
333
+ setIsProcessing(false);
334
+ }
335
+ };
336
+
337
+ return (
338
+ <div className="space-y-3">
339
+ {/* Vision Mode Banner */}
340
+ {visionMode && (
341
+ <div className="p-2 bg-purple-50 border border-purple-200 rounded-md">
342
+ <div className="flex items-center gap-2 text-purple-700 font-medium text-xs mb-1">
343
+ <Eye className="h-3 w-3" />
344
+ <span>Vision Mode Active</span>
345
+ </div>
346
+ {visionFocusedElements.length > 0 ? (
347
+ <div className="text-purple-600 text-xs">
348
+ {visionFocusedElements.length} element{visionFocusedElements.length !== 1 ? "s" : ""} focused
349
+ </div>
350
+ ) : (
351
+ <div className="text-purple-500 text-xs">
352
+ Click elements to focus AI attention, then describe your changes
353
+ </div>
354
+ )}
355
+ </div>
356
+ )}
357
+
358
+ {/* AI Hint - only show when no messages yet and not in vision mode */}
359
+ {messages.length === 0 && componentType !== "all" && !visionMode && (
360
+ <p className="text-xs text-gray-500 italic">
361
+ Describe any styling changes you'd like to make to this component.
362
+ </p>
363
+ )}
364
+
365
+ {/* Chat Messages */}
366
+ {messages.length > 0 && (
367
+ <div className="max-h-48 overflow-y-auto space-y-2 p-2 rounded border border-gray-200 bg-gray-50">
368
+ {messages.map((msg) => (
369
+ <div
370
+ key={msg.id}
371
+ className={cn(
372
+ "text-xs p-2 rounded",
373
+ msg.role === "user"
374
+ ? "bg-[#00A3E1]/10 text-gray-800 ml-4"
375
+ : "bg-white border border-gray-200 mr-4"
376
+ )}
377
+ >
378
+ <div className="flex items-start gap-2">
379
+ {msg.role === "assistant" && (
380
+ <Sparkles className="h-3 w-3 text-[#00A3E1] mt-0.5 flex-shrink-0" />
381
+ )}
382
+ <span id="summary-row-span-msgcontent">{msg.content}</span>
383
+ </div>
384
+ </div>
385
+ ))}
386
+ <div ref={messagesEndRef} />
387
+ </div>
388
+ )}
389
+
390
+ {/* Input */}
391
+ <div className="flex gap-2">
392
+ <input
393
+ type="text"
394
+ value={input}
395
+ onChange={(e) => setInput(e.target.value)}
396
+ onKeyDown={(e) => {
397
+ if (e.key === "Enter" && !e.shiftKey) {
398
+ e.preventDefault();
399
+ handleSend(input);
400
+ }
401
+ }}
402
+ placeholder={
403
+ visionMode
404
+ ? "Describe what changes you want to make on this page..."
405
+ : componentType === "all"
406
+ ? "Select a component to start editing..."
407
+ : `Describe changes to ${componentName}...`
408
+ }
409
+ disabled={isProcessing}
410
+ className={cn(
411
+ "flex-1 px-3 py-2 text-xs rounded border",
412
+ visionMode
413
+ ? "border-purple-200 focus:ring-purple-500 focus:border-purple-500"
414
+ : "border-gray-200 focus:ring-[#00A3E1] focus:border-[#00A3E1]",
415
+ "focus:outline-none focus:ring-1",
416
+ "placeholder:text-gray-400",
417
+ "disabled:opacity-50 disabled:bg-gray-50"
418
+ )}
419
+ />
420
+ <button
421
+ onClick={() => handleSend(input)}
422
+ disabled={isProcessing || !input.trim()}
423
+ className={cn(
424
+ "px-3 py-2 rounded transition-colors",
425
+ visionMode
426
+ ? "bg-purple-600 text-white hover:bg-purple-700"
427
+ : "bg-[#00A3E1] text-white hover:bg-[#0090c8]",
428
+ "disabled:opacity-50 disabled:cursor-not-allowed"
429
+ )}
430
+ >
431
+ {isProcessing ? (
432
+ <Loader2 className="h-4 w-4 animate-spin" />
433
+ ) : (
434
+ <Send className="h-4 w-4" />
435
+ )}
436
+ </button>
437
+ </div>
438
+
439
+ {/* Processing Indicator */}
440
+ {isProcessing && (
441
+ <div className={cn(
442
+ "flex items-center gap-2 text-xs",
443
+ visionMode ? "text-purple-600" : "text-gray-500"
444
+ )}>
445
+ <Loader2 className="h-3 w-3 animate-spin" />
446
+ <span>
447
+ {visionMode
448
+ ? "AI is analyzing the page screenshot and generating changes..."
449
+ : "AI is analyzing and generating changes..."}
450
+ </span>
451
+ </div>
452
+ )}
453
+ </div>
454
+ );
455
+ }