Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions apps/extension/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -34,7 +35,7 @@ interface ChatViewProps {
errorCount?: number;
// Image attachment props
imageAttachmentsEnabled?: boolean;
onCaptureScreenshot?: () => Promise<string | null>;
onCaptureScreenshot?: (mode: 'visible') => Promise<string | null>;
screenshotBehavior?: 'disabled' | 'ask' | 'auto';
/** Callback to register the addImage function for external use */
onRegisterAddImage?: (addImage: (dataUrl: string, source: 'screenshot') => Promise<void>) => void;
Expand All @@ -44,6 +45,7 @@ export function ChatView({
session,
messages,
isStreaming,
isSending = false,
onSendMessage,
onAbort,
onChangeModel,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -457,6 +459,19 @@ export function ChatView({
))
)}

{/* Sending indicator - shown when uploading images / initiating request */}
{isSending && !isStreaming && (
<div className="flex items-center gap-3 p-3 bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-lg border border-amber-100 dark:border-amber-800/50">
<div className="flex items-center gap-2">
<Upload className="w-5 h-5 text-amber-600 dark:text-amber-400 animate-pulse" />
<Loader2 className="w-4 h-4 text-amber-500 animate-spin" />
</div>
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">
Sending your message...
</span>
</div>
)}

{/* C.4 - Enhanced thinking indicator */}
{isStreaming && (
<div className="flex items-center gap-3 p-3 bg-gradient-to-r from-primary-50 to-indigo-50 dark:from-primary-900/20 dark:to-indigo-900/20 rounded-lg border border-primary-100 dark:border-primary-800/50">
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -667,14 +682,21 @@ export function ChatView({
</button>
)}

{isStreaming ? (
{isStreaming || isSending ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center w-12 h-12 rounded-xl bg-red-500 hover:bg-red-600 text-white transition-colors shrink-0"
title="Stop"
className={cn(
"flex items-center justify-center w-12 h-12 rounded-xl text-white transition-colors shrink-0",
isStreaming ? "bg-red-500 hover:bg-red-600" : "bg-amber-500 hover:bg-amber-600"
)}
title={isStreaming ? "Stop" : "Cancel"}
>
<Square className="w-5 h-5" />
{isSending && !isStreaming ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Square className="w-5 h-5" />
)}
</button>
) : (
<button
Expand Down
38 changes: 20 additions & 18 deletions apps/extension/src/components/ImageAttachmentZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface ImageAttachmentZoneProps {
/** Whether attachments are enabled */
enabled?: boolean;
/** Callback to capture screenshot */
onCaptureScreenshot?: () => void;
onCaptureScreenshot?: (mode: 'visible') => void;
/** Whether screenshot capture is in progress */
isCapturingScreenshot?: boolean;
/** Whether screenshot button should be shown */
Expand Down Expand Up @@ -206,24 +206,26 @@ export function ImageAttachmentZone({
</div>
)}

{/* Screenshot button */}
{/* Screenshot buttons */}
{showScreenshotButton && onCaptureScreenshot && !isAtLimit && (
<button
onClick={onCaptureScreenshot}
disabled={isCapturingScreenshot}
className={cn(
'w-16 h-16 rounded-lg border-2',
'border-gray-300 dark:border-gray-600 hover:border-primary-400 dark:hover:border-primary-500',
'flex flex-col items-center justify-center',
'text-gray-500 dark:text-gray-400 hover:text-primary-500 text-xs',
'transition-colors',
isCapturingScreenshot && 'opacity-50 cursor-not-allowed'
)}
title="Capture screenshot"
>
<Camera className={cn('w-5 h-5 mb-0.5', isCapturingScreenshot && 'animate-pulse')} />
<span>Screen</span>
</button>
<>
<button
onClick={() => onCaptureScreenshot('visible')}
disabled={isCapturingScreenshot}
className={cn(
'w-16 h-16 rounded-lg border-2',
'border-gray-300 dark:border-gray-600 hover:border-primary-400 dark:hover:border-primary-500',
'flex flex-col items-center justify-center',
'text-gray-500 dark:text-gray-400 hover:text-primary-500 text-xs',
'transition-colors',
isCapturingScreenshot && 'opacity-50 cursor-not-allowed'
)}
title="Capture visible screen"
>
<Camera className={cn('w-5 h-5 mb-0.5', isCapturingScreenshot && 'animate-pulse')} />
<span>Screen</span>
</button>
</>
)}
</div>
)}
Expand Down
10 changes: 9 additions & 1 deletion apps/extension/src/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -125,6 +125,14 @@ export function MessageBubble({ message, onReplaceText }: MessageBubbleProps) {
</div>
)}

{/* Error display */}
{message.metadata?.error && (
<div className="mt-2 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-2 rounded-lg flex items-start gap-2 border border-red-100 dark:border-red-800/50">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div className="flex-1 whitespace-pre-wrap">{message.metadata.error}</div>
</div>
)}

{/* Images-only indicator when no text */}
{!message.content && hasImages && (
<div className={cn(
Expand Down
33 changes: 22 additions & 11 deletions apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,24 +407,35 @@ async function handleMessage(
* @param targetLanguage - Target language for translation (optional)
*/
function buildQuickActionPrompt(action: string, selectedText: string, targetLanguage?: string): string {
const baseInstruction = 'CRITICAL: Output ONLY the requested content. No explanations, no XML tags, no <reminder> blocks, no conversational text.';

const actionPrompts: Record<string, string> = {
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 <reminder>...</reminder> 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(/<reminder>[\s\S]*?<\/reminder>/g, '').trim();
}

/**
Expand Down Expand Up @@ -501,7 +512,7 @@ async function handleStreamingQuickAction(
type: 'QUICK_ACTION_STREAM_DELTA',
actionId,
delta: '',
fullContent: event.content || '',
fullContent: sanitizeQuickActionContent(event.content || ''),
});
break;

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');

Expand All @@ -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;
Expand All @@ -279,6 +342,7 @@ function attachButtonHandlers(shadow: ShadowRoot): void {
hasContext: !!currentContext
});

// Also prevent default here just in case
e.preventDefault();
e.stopPropagation();

Expand All @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading