diff --git a/apps/extension/src/components/ChatView.tsx b/apps/extension/src/components/ChatView.tsx index b3c1906..1859c53 100644 --- a/apps/extension/src/components/ChatView.tsx +++ b/apps/extension/src/components/ChatView.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react'; -import { Send, Square, Loader2, Cpu, ChevronDown, Brain, Sparkles, Globe, AlertTriangle, ImagePlus } from 'lucide-react'; +import { Send, Square, Loader2, Cpu, ChevronDown, Brain, Sparkles, Globe, AlertTriangle, ImagePlus, Upload } from 'lucide-react'; import { cn } from '../lib/utils'; import { MessageBubble } from './MessageBubble'; import { ImageAttachmentZone } from './ImageAttachmentZone'; @@ -19,6 +19,7 @@ interface ChatViewProps { session: Session | null; messages: Message[]; isStreaming: boolean; + isSending?: boolean; onSendMessage: (content: string, useContext?: boolean, images?: ImagePayload[]) => void; onAbort: () => void; onChangeModel?: (model: string) => void; @@ -34,7 +35,7 @@ interface ChatViewProps { errorCount?: number; // Image attachment props imageAttachmentsEnabled?: boolean; - onCaptureScreenshot?: () => Promise; + onCaptureScreenshot?: (mode: 'visible') => Promise; screenshotBehavior?: 'disabled' | 'ask' | 'auto'; /** Callback to register the addImage function for external use */ onRegisterAddImage?: (addImage: (dataUrl: string, source: 'screenshot') => Promise) => void; @@ -44,6 +45,7 @@ export function ChatView({ session, messages, isStreaming, + isSending = false, onSendMessage, onAbort, onChangeModel, @@ -123,7 +125,7 @@ export function ChatView({ // Insert pending text when provided (without replacing existing draft) useEffect(() => { if (pendingText) { - const maxLength = 5000; + const maxLength = 50000; setInput((prev) => { const textToInsert = pendingText.length > maxLength ? pendingText.substring(0, maxLength) : pendingText; @@ -181,12 +183,12 @@ export function ChatView({ }, [imageAttachmentsEnabled, handlePaste]); // Handle screenshot capture - const handleCaptureScreenshot = useCallback(async () => { + const handleCaptureScreenshot = useCallback(async (mode: 'visible') => { if (!onCaptureScreenshot || isAtLimit) return; setIsCapturingScreenshot(true); try { - const dataUrl = await onCaptureScreenshot(); + const dataUrl = await onCaptureScreenshot(mode); if (dataUrl) { await addImage(dataUrl, 'screenshot'); } @@ -242,7 +244,7 @@ export function ChatView({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (disabled || isStreaming) return; + if (disabled || isStreaming || isSending) return; // Allow sending with images even without text const hasContent = input.trim() || images.length > 0; @@ -457,6 +459,19 @@ export function ChatView({ )) )} + {/* Sending indicator - shown when uploading images / initiating request */} + {isSending && !isStreaming && ( +
+
+ + +
+ + Sending your message... + +
+ )} + {/* C.4 - Enhanced thinking indicator */} {isStreaming && (
@@ -605,7 +620,7 @@ export function ChatView({ onBlur={(e) => updateCursorSelection(e.currentTarget)} onPaste={handleInputPaste} placeholder={placeholderText} - disabled={disabled || isStreaming} + disabled={disabled || isStreaming || isSending} rows={1} className={cn( 'w-full px-4 text-sm rounded-xl border resize-none', @@ -667,14 +682,21 @@ export function ChatView({ )} - {isStreaming ? ( + {isStreaming || isSending ? ( ) : (
)} - {/* Screenshot button */} + {/* Screenshot buttons */} {showScreenshotButton && onCaptureScreenshot && !isAtLimit && ( - + <> + + )} )} diff --git a/apps/extension/src/components/MessageBubble.tsx b/apps/extension/src/components/MessageBubble.tsx index 982af43..8e8ec4e 100644 --- a/apps/extension/src/components/MessageBubble.tsx +++ b/apps/extension/src/components/MessageBubble.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { User, Bot, Wrench, Copy, Check, Replace, Image as ImageIcon } from 'lucide-react'; +import { User, Bot, Wrench, Copy, Check, Replace, Image as ImageIcon, AlertTriangle } from 'lucide-react'; import { cn } from '../lib/utils'; import { ImageThumbnail } from './ImageThumbnail'; import { ImageLightbox } from './ImageLightbox'; @@ -125,6 +125,14 @@ export function MessageBubble({ message, onReplaceText }: MessageBubbleProps) { )} + {/* Error display */} + {message.metadata?.error && ( +
+ +
{message.metadata.error}
+
+ )} + {/* Images-only indicator when no text */} {!message.content && hasImages && (
= { - explain: `Explain the following text clearly and concisely:\n\n${selectedText}`, + explain: `Explain the following text clearly and concisely:\n\n${selectedText}\n\n${baseInstruction}`, translate: targetLanguage - ? `Translate the following text to ${targetLanguage}. Return only the translated text without any explanation:\n\n${selectedText}` - : `Translate the following text:\n\n${selectedText}`, - rewrite: `Rewrite the following text for clarity and improved style. Return only the rewritten text without any explanation:\n\n${selectedText}`, - fix_grammar: `Fix the grammar and spelling in the following text. Return only the corrected text without any explanation:\n\n${selectedText}`, - summarize: `Summarize the following text briefly:\n\n${selectedText}`, - expand: `Expand on the following text with more details:\n\n${selectedText}`, + ? `Translate the following text to ${targetLanguage}. Return only the translated text without any explanation:\n\n${selectedText}\n\n${baseInstruction}` + : `Translate the following text:\n\n${selectedText}\n\n${baseInstruction}`, + rewrite: `Rewrite the following text for clarity and improved style. Return only the rewritten text without any explanation:\n\n${selectedText}\n\n${baseInstruction}`, + fix_grammar: `Fix the grammar and spelling in the following text. Return only the corrected text without any explanation:\n\n${selectedText}\n\n${baseInstruction}`, + summarize: `Provide a clear, detailed, and accurate summary of the following text, highlighting the main points:\n\n${selectedText}\n\n${baseInstruction}`, + expand: `Expand on the following text with more details:\n\n${selectedText}\n\n${baseInstruction}`, }; // Handle tone-specific rewrites if (action.startsWith('rewrite_')) { const tone = action.replace('rewrite_', ''); - return `Rewrite the following text in a ${tone} tone. Return only the rewritten text without any explanation:\n\n${selectedText}`; + return `Rewrite the following text in a ${tone} tone. Return only the rewritten text without any explanation:\n\n${selectedText}\n\n${baseInstruction}`; } - return actionPrompts[action] || `Process the following text:\n\n${selectedText}`; + return actionPrompts[action] || `Process the following text:\n\n${selectedText}\n\n${baseInstruction}`; +} + +/** + * Removes AI artifacts like ... from the response + */ +function sanitizeQuickActionContent(content: string): string { + if (!content) return content; + // Remove matched XML-like blocks that might be injected by the system prompt + return content.replace(/[\s\S]*?<\/reminder>/g, '').trim(); } /** @@ -501,7 +512,7 @@ async function handleStreamingQuickAction( type: 'QUICK_ACTION_STREAM_DELTA', actionId, delta: '', - fullContent: event.content || '', + fullContent: sanitizeQuickActionContent(event.content || ''), }); break; @@ -510,7 +521,7 @@ async function handleStreamingQuickAction( await chrome.tabs.sendMessage(tabId, { type: 'QUICK_ACTION_STREAM_COMPLETE', actionId, - finalContent: event.content || '', + finalContent: sanitizeQuickActionContent(event.content || ''), }); // NOTE: Do NOT store pendingAction here to avoid duplicate processing diff --git a/apps/extension/src/entrypoints/content/FloatingResponsePopup.ts b/apps/extension/src/entrypoints/content/FloatingResponsePopup.ts index e750f5c..4b95063 100644 --- a/apps/extension/src/entrypoints/content/FloatingResponsePopup.ts +++ b/apps/extension/src/entrypoints/content/FloatingResponsePopup.ts @@ -12,6 +12,7 @@ let isComplete = false; let currentContext: SelectionContext | null = null; let onDismissCallback: (() => void) | null = null; let anchorPosition: PopupPosition | null = null; +let hasBeenDragged = false; interface PopupPosition { x: number; @@ -34,6 +35,7 @@ export function createFloatingResponsePopup( currentContent = ''; isComplete = false; onDismissCallback = onDismiss || null; + hasBeenDragged = false; // Create shadow host for style isolation popupContainer = document.createElement('div'); @@ -70,6 +72,9 @@ export function createFloatingResponsePopup( // Attach button handlers for initial loading state (dismiss button) attachButtonHandlers(shadow); + // Setup drag handlers + setupDragHandlers(shadow); + // Auto-replace if behavior is 'auto' if (behavior === 'auto') { // Will be handled when content is complete @@ -195,7 +200,7 @@ export function getCurrentContent(): string { // ============================================================================ function positionPopup(position?: PopupPosition): void { - if (!popupContainer) return; + if (!popupContainer || hasBeenDragged) return; const referencePosition = position || anchorPosition; if (!referencePosition) return; @@ -250,6 +255,53 @@ function setupKeyboardHandlers(_shadow: ShadowRoot): void { } } +function setupDragHandlers(shadow: ShadowRoot): void { + const header = shadow.querySelector('.header') as HTMLElement; + if (!header || !popupContainer) return; + + let isDragging = false; + let startX = 0; + let startY = 0; + let initialLeft = 0; + let initialTop = 0; + + header.addEventListener('pointerdown', (e) => { + isDragging = true; + hasBeenDragged = true; + startX = e.clientX; + startY = e.clientY; + + const rect = popupContainer!.getBoundingClientRect(); + initialLeft = rect.left; + initialTop = rect.top; + + header.setPointerCapture(e.pointerId); + }); + + header.addEventListener('pointermove', (e) => { + if (!isDragging || !popupContainer) return; + + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + const newLeft = initialLeft + dx; + const newTop = initialTop + dy; + + popupContainer.style.left = `${newLeft}px`; + popupContainer.style.top = `${newTop}px`; + }); + + header.addEventListener('pointerup', (e) => { + isDragging = false; + header.releasePointerCapture(e.pointerId); + }); + + header.addEventListener('pointercancel', (e) => { + isDragging = false; + header.releasePointerCapture(e.pointerId); + }); +} + function attachButtonHandlers(shadow: ShadowRoot): void { console.log('[FloatingPopup] attachButtonHandlers called'); @@ -264,8 +316,19 @@ function attachButtonHandlers(shadow: ShadowRoot): void { const existingHandler = (popup as any).__clickHandler; if (existingHandler) { popup.removeEventListener('click', existingHandler); + popup.removeEventListener('mousedown', (popup as any).__mouseDownHandler); } + // Prevent focus loss from the active element when clicking buttons + const mouseDownHandler = (e: Event) => { + const target = e.target as HTMLElement; + const button = target.closest('button'); + if (button) { + // Prevent focus shift + e.preventDefault(); + } + }; + // Create and store new handler const clickHandler = (e: Event) => { const target = e.target as HTMLElement; @@ -279,6 +342,7 @@ function attachButtonHandlers(shadow: ShadowRoot): void { hasContext: !!currentContext }); + // Also prevent default here just in case e.preventDefault(); e.stopPropagation(); @@ -292,6 +356,8 @@ function attachButtonHandlers(shadow: ShadowRoot): void { }; (popup as any).__clickHandler = clickHandler; + (popup as any).__mouseDownHandler = mouseDownHandler; + popup.addEventListener('mousedown', mouseDownHandler); popup.addEventListener('click', clickHandler); } @@ -368,6 +434,12 @@ function getStyles(): string { padding: 10px 14px; background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); color: white; + cursor: grab; + user-select: none; + } + + .header:active { + cursor: grabbing; } .header-title { diff --git a/apps/extension/src/entrypoints/content/SelectionToolbar.ts b/apps/extension/src/entrypoints/content/SelectionToolbar.ts index 7a5c89a..cefe7f7 100644 --- a/apps/extension/src/entrypoints/content/SelectionToolbar.ts +++ b/apps/extension/src/entrypoints/content/SelectionToolbar.ts @@ -204,12 +204,59 @@ export function createSelectionToolbar( .tone-item .emoji { font-size: 14px; } + + .trigger-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); + color: white; + cursor: pointer; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s, opacity 0.15s; + flex-shrink: 0; + } + + .actions-container { + display: flex; + gap: 4px; + max-width: 0; + overflow: hidden; + opacity: 0; + transition: max-width 0.25s ease, opacity 0.2s ease, margin-left 0.2s; + } + + .toolbar:hover .actions-container, .toolbar.expanded .actions-container { + max-width: 400px; + opacity: 1; + margin-left: 4px; + overflow: visible; /* needed for submenus! */ + } `; // Create toolbar element const toolbar = document.createElement('div'); toolbar.className = 'toolbar'; + // Trigger button + const triggerBtn = document.createElement('button'); + triggerBtn.className = 'trigger-btn'; + triggerBtn.textContent = '✨'; + triggerBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + toolbar.classList.toggle('expanded'); + }); + toolbar.appendChild(triggerBtn); + + // Actions wrapper + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'actions-container'; + // Add action buttons QUICK_ACTIONS.forEach((action) => { const btn = document.createElement('button'); @@ -225,13 +272,13 @@ export function createSelectionToolbar( e.stopPropagation(); onAction(action.id); }); - toolbar.appendChild(btn); + actionsContainer.appendChild(btn); }); // Add divider const divider = document.createElement('div'); divider.className = 'divider'; - toolbar.appendChild(divider); + actionsContainer.appendChild(divider); // C.5 - Enhanced tone menu with submenu const toneContainer = document.createElement('div'); @@ -310,7 +357,7 @@ export function createSelectionToolbar( toneContainer.appendChild(toneBtn); toneContainer.appendChild(toneMenu); - toolbar.appendChild(toneContainer); + actionsContainer.appendChild(toneContainer); // Add "Open Chat" button const chatBtn = document.createElement('button'); @@ -329,7 +376,9 @@ export function createSelectionToolbar( }); removeSelectionToolbar(); }); - toolbar.appendChild(chatBtn); + actionsContainer.appendChild(chatBtn); + + toolbar.appendChild(actionsContainer); shadow.appendChild(styles); shadow.appendChild(toolbar); diff --git a/apps/extension/src/entrypoints/content/index.ts b/apps/extension/src/entrypoints/content/index.ts index fe28430..37fbbde 100644 --- a/apps/extension/src/entrypoints/content/index.ts +++ b/apps/extension/src/entrypoints/content/index.ts @@ -178,6 +178,10 @@ export default defineContentScript({ const streamMsg = message as QuickActionStreamMessage; if (streamMsg.type === 'QUICK_ACTION_STREAM_START' && contextToUse && rectToUse) { + // Hide toolbar immediately + removeSelectionToolbar(); + isToolbarVisible = false; + // Show floating popup for streaming response createFloatingResponsePopup( { x: rectToUse.left + rectToUse.width / 2, y: rectToUse.bottom }, diff --git a/apps/extension/src/entrypoints/options/OptionsPage.tsx b/apps/extension/src/entrypoints/options/OptionsPage.tsx index 7ce4f6b..1cd4f42 100644 --- a/apps/extension/src/entrypoints/options/OptionsPage.tsx +++ b/apps/extension/src/entrypoints/options/OptionsPage.tsx @@ -91,7 +91,7 @@ export function OptionsPage() { if (!isLoaded) { return (
-
+
); } @@ -125,7 +125,7 @@ export function OptionsPage() {
@@ -162,8 +162,8 @@ export function OptionsPage() { onClick={() => updateLocalSetting('theme', theme)} className={`flex-1 py-2 px-4 rounded-lg border transition-colors ${ localSettings.theme === theme - ? 'border-primary bg-primary/10 text-primary' - : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary' + ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400' + : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary-400' }`} > {theme === 'light' && '☀️ Light'} @@ -290,7 +290,7 @@ export function OptionsPage() { className="sr-only" />
updateLocalSetting('screenshotBehavior', behavior)} className={`flex-1 py-2 px-3 rounded-lg border transition-colors text-sm ${ localSettings.screenshotBehavior === behavior - ? 'border-primary bg-primary/10 text-primary' - : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary' + ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400' + : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary-400' }`} > {behavior === 'disabled' && '🚫 Disabled'} @@ -390,8 +390,8 @@ export function OptionsPage() { onClick={() => updateLocalSetting('communicationMode', mode)} className={`flex-1 py-2 px-4 rounded-lg border transition-colors ${ localSettings.communicationMode === mode - ? 'border-primary bg-primary/10 text-primary' - : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary' + ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400' + : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary-400' }`} > {mode === 'http' && '🌐 HTTP Server'} @@ -424,8 +424,8 @@ export function OptionsPage() { onClick={() => updateLocalSetting('textReplacementBehavior', behavior)} className={`flex-1 py-2 px-3 rounded-lg border transition-colors text-sm ${ localSettings.textReplacementBehavior === behavior - ? 'border-primary bg-primary/10 text-primary' - : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary' + ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400' + : 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-primary-400' }`} > {behavior === 'ask' && '❓ Ask'} @@ -566,7 +566,7 @@ export function OptionsPage() { diff --git a/apps/extension/src/entrypoints/sidepanel/SidePanel.tsx b/apps/extension/src/entrypoints/sidepanel/SidePanel.tsx index 74f9b70..93d7454 100644 --- a/apps/extension/src/entrypoints/sidepanel/SidePanel.tsx +++ b/apps/extension/src/entrypoints/sidepanel/SidePanel.tsx @@ -67,6 +67,7 @@ export function SidePanel() { const { messages, isStreaming, + isSending, sendMessage, abortMessage, } = useChat(activeSession?.id); @@ -248,6 +249,10 @@ export function SidePanel() { } }, [captureVisibleTabScreenshot]); + const handleCaptureScreenshot = useCallback(async (_mode: 'visible') => { + return captureVisibleTabScreenshot(); + }, [captureVisibleTabScreenshot]); + // Toggle context mode and extract context if enabling const handleToggleContextMode = useCallback(async () => { const newState = !contextModeEnabled; @@ -369,6 +374,7 @@ export function SidePanel() { session={activeSession} messages={messages} isStreaming={isStreaming} + isSending={isSending} onSendMessage={handleSendMessage} onAbort={abortMessage} onChangeModel={canChangeSessionModel ? handleChangeSessionModel : undefined} @@ -385,7 +391,7 @@ export function SidePanel() { // Image attachment props imageAttachmentsEnabled={settings.imageAttachmentsEnabled} screenshotBehavior={settings.screenshotBehavior} - onCaptureScreenshot={captureVisibleTabScreenshot} + onCaptureScreenshot={handleCaptureScreenshot} onRegisterAddImage={(fn) => { addImageToChatRef.current = fn; }} /> diff --git a/apps/extension/src/hooks/useChat.ts b/apps/extension/src/hooks/useChat.ts index ed0b3bb..5f2eb3e 100644 --- a/apps/extension/src/hooks/useChat.ts +++ b/apps/extension/src/hooks/useChat.ts @@ -30,6 +30,7 @@ function isRecoverableSessionError(error: unknown): boolean { export function useChat(sessionId: string | undefined) { const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); + const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); const [isExtractingContext, setIsExtractingContext] = useState(false); const abortControllerRef = useRef(null); @@ -52,6 +53,7 @@ export function useChat(sessionId: string | undefined) { abortControllerRef.current = null; } setIsStreaming(false); + setIsSending(false); loadMessages(sessionId); } else { setMessages([]); @@ -72,8 +74,8 @@ export function useChat(sessionId: string | undefined) { const sendMessage = useCallback(async (content: string, options?: SendMessageOptions | MessageContext) => { console.log('[useChat] sendMessage called:', { sessionId, isStreaming, contentLength: content.length }); - if (!sessionId || isStreaming) { - console.log('[useChat] Blocked - no sessionId or already streaming'); + if (!sessionId || isStreaming || isSending) { + console.log('[useChat] Blocked - no sessionId, already streaming, or already sending'); return; } @@ -100,7 +102,7 @@ export function useChat(sessionId: string | undefined) { const requestSessionId = sessionId; setError(null); - setIsStreaming(true); + setIsSending(true); currentMessageRef.current = ''; // Build images for metadata (convert to ImageAttachment-like format for display) @@ -159,13 +161,71 @@ export function useChat(sessionId: string | undefined) { try { abortControllerRef.current = new AbortController(); - // Build request body with optional full context and images + // ---------------------------------------------------------------- + // Phase 1: Pre-upload images (if any) before sending the chat request + // This sends each image individually to the backend so the chat + // request body stays small and avoids payload-too-large crashes. + // ---------------------------------------------------------------- + let preUploadedImages: Array<{ + id: string; + thumbnailUrl: string; + fullImageUrl: string; + fullImagePath: string; + mimeType: string; + dimensions: { width: number; height: number }; + fileSize: number; + }> | undefined; + + if (sendOptions.images && sendOptions.images.length > 0) { + console.log(`[useChat] Pre-uploading ${sendOptions.images.length} images...`); + try { + const uploadResult = await apiClient.uploadImages( + requestSessionId, + userMessage.id, + sendOptions.images.map(img => ({ + id: img.id, + dataUrl: img.dataUrl, + mimeType: img.mimeType, + source: img.source, + })) + ); + if (uploadResult.success && uploadResult.data?.images) { + preUploadedImages = uploadResult.data.images; + console.log(`[useChat] Pre-upload complete: ${preUploadedImages.length} images`); + } else { + console.error('[useChat] Pre-upload failed:', uploadResult.error); + throw new Error(uploadResult.error?.message || 'Failed to upload images'); + } + } catch (uploadErr) { + console.error('[useChat] Image upload error:', uploadErr); + const uploadErrMsg = uploadErr instanceof Error ? uploadErr.message : 'Failed to upload images'; + setError(uploadErrMsg); + setMessages(prev => + prev.map(m => + m.id === assistantMessageId + ? { ...m, metadata: { ...m.metadata, error: `⚠️ Image upload failed: ${uploadErrMsg}` } } + : m + ) + ); + setIsSending(false); + setIsStreaming(false); + abortControllerRef.current = null; + return; + } + } + + // ---------------------------------------------------------------- + // Phase 2: Send the chat request (images are now just references) + // ---------------------------------------------------------------- + setIsStreaming(true); + + // Build request body with optional full context and pre-uploaded image refs const requestBody: { prompt: string; context?: MessageContext; fullContext?: ContextPayload; useContextAwareMode?: boolean; - images?: ImagePayload[]; + preUploadedImages?: typeof preUploadedImages; } = { prompt: content, context: sendOptions.context, @@ -177,10 +237,10 @@ export function useChat(sessionId: string | undefined) { console.log('[useChat] Using context-aware mode with full context'); } - // Add images if provided - if (sendOptions.images && sendOptions.images.length > 0) { - requestBody.images = sendOptions.images; - console.log(`[useChat] Attaching ${sendOptions.images.length} images to message`); + // Attach pre-uploaded image references (NOT base64 data) + if (preUploadedImages && preUploadedImages.length > 0) { + requestBody.preUploadedImages = preUploadedImages; + console.log(`[useChat] Attaching ${preUploadedImages.length} pre-uploaded image refs to message`); } const streamOnce = async () => { @@ -280,6 +340,19 @@ export function useChat(sessionId: string | undefined) { deferredSessionError = streamError; } else { setError(streamError); + setMessages(prev => + prev.map(m => + m.id === assistantMessageId + ? { + ...m, + metadata: { + ...m.metadata, + error: streamError, + }, + } + : m + ) + ); } break; } @@ -330,13 +403,28 @@ export function useChat(sessionId: string | undefined) { console.log('[useChat] Request aborted'); } else { console.error('[useChat] Failed to send message:', err); - setError(err instanceof Error ? err.message : 'Failed to send message'); + const errorMessage = err instanceof Error ? err.message : 'Failed to send message'; + setError(errorMessage); + setMessages(prev => + prev.map(m => + m.id === assistantMessageId + ? { + ...m, + metadata: { + ...m.metadata, + error: errorMessage, + }, + } + : m + ) + ); } } finally { setIsStreaming(false); + setIsSending(false); abortControllerRef.current = null; } - }, [sessionId, isStreaming]); + }, [sessionId, isStreaming, isSending]); const abortMessage = useCallback(async () => { if (abortControllerRef.current) { @@ -352,11 +440,13 @@ export function useChat(sessionId: string | undefined) { } setIsStreaming(false); + setIsSending(false); }, [sessionId]); return { messages, isStreaming, + isSending, isExtractingContext, error, sendMessage, diff --git a/apps/extension/src/hooks/useContextExtraction.ts b/apps/extension/src/hooks/useContextExtraction.ts index 792cd15..0cd40d1 100644 --- a/apps/extension/src/hooks/useContextExtraction.ts +++ b/apps/extension/src/hooks/useContextExtraction.ts @@ -38,6 +38,7 @@ export interface UseContextExtractionResult { extractContext: (sessionId: string, userMessage?: string, captureScreenshot?: boolean) => Promise; captureScreenshot: () => Promise; captureVisibleTabScreenshot: () => Promise; + captureFullPageScreenshot: () => Promise; clearContext: () => void; hasErrors: boolean; errorCount: number; @@ -64,7 +65,7 @@ async function captureVisibleTabScreenshot(): Promise { // Capture the visible tab const dataUrl = await captureVisibleTab(tab.windowId, { format: 'jpeg', - quality: 70, + quality: 90, }); // Get dimensions from the tab's window @@ -84,6 +85,15 @@ async function captureVisibleTabScreenshot(): Promise { } } +/** + * Capture a full page screenshot by scrolling and stitching + */ +async function captureFullPageScreenshot(): Promise { + // The full page screenshot functionality has been removed due to quality and compression issues. + // We only keep the definition to avoid breaking existing imports/types if any, but it returns null. + return null; +} + export function useContextExtraction(): UseContextExtractionResult { const [extractedContext, setExtractedContext] = useState(null); const [platform, setPlatform] = useState(null); @@ -217,6 +227,7 @@ export function useContextExtraction(): UseContextExtractionResult { extractContext, captureScreenshot, captureVisibleTabScreenshot: captureVisibleTabScreenshotSimple, + captureFullPageScreenshot, clearContext, hasErrors: (extractedContext?.text.errors.length ?? 0) > 0, errorCount: extractedContext?.text.errors.length ?? 0, diff --git a/apps/extension/src/services/api-client.ts b/apps/extension/src/services/api-client.ts index 5d5ae45..a199b0b 100644 --- a/apps/extension/src/services/api-client.ts +++ b/apps/extension/src/services/api-client.ts @@ -238,6 +238,72 @@ export class ApiClient { } } + /** + * Pre-upload images to the backend for a message. + * Sends images individually in parallel to avoid body-size issues. + * Returns the processed image references (paths on disk, URLs for display). + */ + async uploadImages( + sessionId: string, + messageId: string, + images: Array<{ id: string; dataUrl: string; mimeType: string; source: string }> + ): Promise; + }>> { + const baseUrl = await this.resolveBaseUrl(); + + // Upload images one at a time to avoid payload limits + const allProcessed: Array<{ + id: string; + thumbnailUrl: string; + fullImageUrl: string; + fullImagePath: string; + mimeType: string; + dimensions: { width: number; height: number }; + fileSize: number; + }> = []; + + // Upload in parallel (each image is its own request) + const uploadPromises = images.map(async (image) => { + const response = await fetch( + `${baseUrl}/api/images/upload/${sessionId}/${messageId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ images: [image] }), + } + ); + + if (!response.ok) { + throw new Error(`Image upload failed: ${response.status} ${response.statusText}`); + } + + const result = await response.json() as ApiResponse<{ images: typeof allProcessed }>; + if (result.success && result.data?.images) { + return result.data.images; + } + throw new Error(result.error?.message || 'Upload failed'); + }); + + const results = await Promise.all(uploadPromises); + for (const imgs of results) { + allProcessed.push(...imgs); + } + + return { + success: true, + data: { images: allProcessed }, + }; + } + /** * Get full URL for a thumbnail image */