feat: Add message queue system with interruption handling (#4179)

Co-authored-by: Zane Staggs <zane@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Spence
2025-08-21 23:56:53 -04:00
committed by GitHub
parent cd0b7d6934
commit 3b6c403146
6 changed files with 1353 additions and 0 deletions
+231
View File
@@ -27,6 +27,14 @@ import { COST_TRACKING_ENABLED } from '../updates';
import { CostTracker } from './bottom_menu/CostTracker';
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
import { Recipe } from '../recipe';
import MessageQueue from './MessageQueue';
import { detectInterruption } from '../utils/interruptionDetector';
interface QueuedMessage {
id: string;
content: string;
timestamp: number;
}
interface PastedImage {
id: string;
@@ -106,6 +114,14 @@ export default function ChatInput({
// Derived state - chatState != Idle means we're in some form of loading state
const isLoading = chatState !== ChatState.Idle;
const wasLoadingRef = useRef(isLoading);
// Queue functionality - ephemeral, only exists in memory for this chat instance
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
const queuePausedRef = useRef(false);
const editingMessageIdRef = useRef<string | null>(null);
const [lastInterruption, setLastInterruption] = useState<string | null>(null);
const { alerts, addAlert, clearAlerts } = useAlerts();
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
null
@@ -126,6 +142,73 @@ export default function ChatInput({
useEffect(() => {
// Debug logging removed - draft functionality is working correctly
}, [chatContext?.contextKey, chatContext?.draft, chatContext]);
// Save queue state (paused/interrupted) to storage
useEffect(() => {
try {
window.sessionStorage.setItem('goose-queue-paused', JSON.stringify(queuePausedRef.current));
} catch (error) {
console.error('Error saving queue pause state:', error);
}
}, [queuedMessages]); // Save when queue changes
useEffect(() => {
try {
window.sessionStorage.setItem('goose-queue-interruption', JSON.stringify(lastInterruption));
} catch (error) {
console.error('Error saving queue interruption state:', error);
}
}, [lastInterruption]);
// Cleanup effect - save final state on component unmount
useEffect(() => {
return () => {
// Save final queue state when component unmounts
try {
window.sessionStorage.setItem('goose-queue-paused', JSON.stringify(queuePausedRef.current));
window.sessionStorage.setItem('goose-queue-interruption', JSON.stringify(lastInterruption));
} catch (error) {
console.error('Error saving queue state on unmount:', error);
}
};
}, [lastInterruption]); // Include lastInterruption in dependency array
// Queue processing
useEffect(() => {
if (wasLoadingRef.current && !isLoading && queuedMessages.length > 0) {
// After an interruption, we should process the interruption message immediately
// The queue is only truly paused if there was an interruption AND we want to keep it paused
const shouldProcessQueue = !queuePausedRef.current || lastInterruption;
if (shouldProcessQueue) {
const nextMessage = queuedMessages[0];
LocalMessageStorage.addMessage(nextMessage.content);
handleSubmit(
new CustomEvent('submit', {
detail: { value: nextMessage.content },
}) as unknown as React.FormEvent
);
setQueuedMessages((prev) => {
const newQueue = prev.slice(1);
// If queue becomes empty after processing, clear the paused state
if (newQueue.length === 0) {
queuePausedRef.current = false;
setLastInterruption(null);
}
return newQueue;
});
// Clear the interruption flag after processing the interruption message
if (lastInterruption) {
setLastInterruption(null);
// Keep the queue paused after sending the interruption message
// User can manually resume if they want to continue with queued messages
queuePausedRef.current = true;
}
}
}
wasLoadingRef.current = isLoading;
}, [isLoading, queuedMessages, handleSubmit, lastInterruption]);
const [mentionPopover, setMentionPopover] = useState<{
isOpen: boolean;
position: { x: number; y: number };
@@ -788,6 +871,54 @@ export default function ChatInput({
}
};
// Helper function to handle interruption and queue logic when loading
const handleInterruptionAndQueue = () => {
if (!isLoading || !displayValue.trim()) {
return false; // Return false if no action was taken
}
const interruptionMatch = detectInterruption(displayValue.trim());
if (interruptionMatch && interruptionMatch.shouldInterrupt) {
setLastInterruption(interruptionMatch.matchedText);
if (onStop) onStop();
queuePausedRef.current = true;
// For interruptions, we need to queue the message to be sent after the stop completes
// rather than trying to send it immediately while the system is still loading
const interruptionMessage = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
content: displayValue.trim(),
timestamp: Date.now(),
};
// Add the interruption message to the front of the queue so it gets sent first
setQueuedMessages((prev) => [interruptionMessage, ...prev]);
setDisplayValue('');
setValue('');
return true; // Return true if interruption was handled
}
const newMessage = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
content: displayValue.trim(),
timestamp: Date.now(),
};
setQueuedMessages((prev) => {
const newQueue = [...prev, newMessage];
// If adding to an empty queue, reset the paused state
if (prev.length === 0) {
queuePausedRef.current = false;
setLastInterruption(null);
}
return newQueue;
});
setDisplayValue('');
setValue('');
return true; // Return true if message was queued
};
const performSubmit = () => {
const validPastedImageFilesPaths = pastedImages
.filter((img) => img.filePath && !img.error && !img.isLoading)
@@ -818,6 +949,17 @@ export default function ChatInput({
new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent
);
// Auto-resume queue after sending a NON-interruption message (if it was paused due to interruption)
if (
queuePausedRef.current &&
lastInterruption &&
textToSend &&
!detectInterruption(textToSend)
) {
queuePausedRef.current = false;
setLastInterruption(null);
}
setDisplayValue('');
setValue('');
setPastedImages([]);
@@ -892,6 +1034,12 @@ export default function ChatInput({
}
evt.preventDefault();
// Handle interruption and queue logic
if (handleInterruptionAndQueue()) {
return;
}
const canSubmit =
!isLoading &&
!isLoadingCompaction &&
@@ -956,6 +1104,74 @@ export default function ChatInput({
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
const isAnyDroppedFileLoading = allDroppedFiles.some((file) => file.isLoading);
// Queue management functions - no storage persistence, only in-memory
const handleRemoveQueuedMessage = (messageId: string) => {
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
};
const handleClearQueue = () => {
setQueuedMessages([]);
queuePausedRef.current = false;
setLastInterruption(null);
};
const handleReorderMessages = (reorderedMessages: QueuedMessage[]) => {
setQueuedMessages(reorderedMessages);
};
const handleEditMessage = (messageId: string, newContent: string) => {
setQueuedMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, content: newContent } : msg))
);
};
const handleStopAndSend = (messageId: string) => {
const messageToSend = queuedMessages.find((msg) => msg.id === messageId);
if (!messageToSend) return;
// Stop current processing and temporarily pause queue to prevent double-send
if (onStop) onStop();
const wasPaused = queuePausedRef.current;
queuePausedRef.current = true;
// Remove the message from queue and send it immediately
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
LocalMessageStorage.addMessage(messageToSend.content);
handleSubmit(
new CustomEvent('submit', {
detail: { value: messageToSend.content },
}) as unknown as React.FormEvent
);
// Restore previous pause state after a brief delay to prevent race condition
setTimeout(() => {
queuePausedRef.current = wasPaused;
}, 100);
};
const handleResumeQueue = () => {
queuePausedRef.current = false;
setLastInterruption(null);
if (!isLoading && queuedMessages.length > 0) {
const nextMessage = queuedMessages[0];
LocalMessageStorage.addMessage(nextMessage.content);
handleSubmit(
new CustomEvent('submit', {
detail: { value: nextMessage.content },
}) as unknown as React.FormEvent
);
setQueuedMessages((prev) => {
const newQueue = prev.slice(1);
// If queue becomes empty after processing, clear the paused state
if (newQueue.length === 0) {
queuePausedRef.current = false;
setLastInterruption(null);
}
return newQueue;
});
}
};
return (
<div
className={`flex flex-col relative h-auto p-4 transition-colors ${
@@ -969,6 +1185,21 @@ export default function ChatInput({
onDrop={handleLocalDrop}
onDragOver={handleLocalDragOver}
>
{/* Message Queue Display */}
{queuedMessages.length > 0 && (
<MessageQueue
queuedMessages={queuedMessages}
onRemoveMessage={handleRemoveQueuedMessage}
onClearQueue={handleClearQueue}
onStopAndSend={handleStopAndSend}
onReorderMessages={handleReorderMessages}
onEditMessage={handleEditMessage}
onTriggerQueueProcessing={handleResumeQueue}
editingMessageIdRef={editingMessageIdRef}
isPaused={queuePausedRef.current}
className="border-b border-borderSubtle"
/>
)}
{/* Input row with inline action buttons wrapped in form */}
<form onSubmit={onFormSubmit} className="relative flex items-end">
<div className="relative flex-1">
@@ -0,0 +1,223 @@
import React, { useState, useEffect } from 'react';
import { AlertTriangle, StopCircle, PauseCircle, RotateCcw, Zap, AlertCircle } from 'lucide-react';
import { Button } from './ui/button';
import { InterruptionMatch } from '../utils/interruptionDetector';
interface InterruptionHandlerProps {
match: InterruptionMatch | null;
onConfirmInterruption: () => void;
onCancelInterruption: () => void;
onRedirect?: (newMessage: string) => void;
className?: string;
}
export const InterruptionHandler: React.FC<InterruptionHandlerProps> = ({
match,
onConfirmInterruption,
onCancelInterruption,
onRedirect,
className = '',
}) => {
const [redirectMessage, setRedirectMessage] = useState('');
const [showRedirectInput, setShowRedirectInput] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (match) {
setIsVisible(true);
if (match.keyword.action === 'redirect') {
setShowRedirectInput(true);
} else {
setShowRedirectInput(false);
setRedirectMessage('');
}
} else {
setIsVisible(false);
}
}, [match]);
if (!match) {
return null;
}
const getIcon = () => {
switch (match.keyword.action) {
case 'stop':
return <StopCircle className="w-6 h-6 text-red-500" />;
case 'pause':
return <PauseCircle className="w-6 h-6 text-amber-500" />;
case 'redirect':
return <RotateCcw className="w-6 h-6 text-blue-500" />;
default:
return <AlertTriangle className="w-6 h-6 text-orange-500" />;
}
};
const getActionColor = () => {
switch (match.keyword.action) {
case 'stop':
return {
bg: 'bg-red-50 dark:bg-red-950/20',
border: 'border-red-200 dark:border-red-800/50',
text: 'text-red-800 dark:text-red-200',
accent: 'text-red-600 dark:text-red-400'
};
case 'pause':
return {
bg: 'bg-amber-50 dark:bg-amber-950/20',
border: 'border-amber-200 dark:border-amber-800/50',
text: 'text-amber-800 dark:text-amber-200',
accent: 'text-amber-600 dark:text-amber-400'
};
case 'redirect':
return {
bg: 'bg-blue-50 dark:bg-blue-950/20',
border: 'border-blue-200 dark:border-blue-800/50',
text: 'text-blue-800 dark:text-blue-200',
accent: 'text-blue-600 dark:text-blue-400'
};
default:
return {
bg: 'bg-orange-50 dark:bg-orange-950/20',
border: 'border-orange-200 dark:border-orange-800/50',
text: 'text-orange-800 dark:text-orange-200',
accent: 'text-orange-600 dark:text-orange-400'
};
}
};
const colors = getActionColor();
const handleConfirm = () => {
if (showRedirectInput && onRedirect && redirectMessage.trim()) {
onRedirect(redirectMessage.trim());
} else {
onConfirmInterruption();
}
};
const getActionTitle = () => {
switch (match.keyword.action) {
case 'stop': return 'Stop Processing';
case 'pause': return 'Pause Processing';
case 'redirect': return 'Redirect Processing';
default: return 'Interrupt Processing';
}
};
const getActionDescription = () => {
switch (match.keyword.action) {
case 'stop':
return 'This will immediately stop the current processing and clear any queued messages.';
case 'pause':
return 'This will pause the current processing. Queued messages will be preserved.';
case 'redirect':
return 'This will stop current processing and redirect to a new task.';
default:
return 'This will interrupt the current processing.';
}
};
return (
<div className={`fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 ${className}`}>
<div className={`w-full max-w-md mx-auto transition-all duration-300 ease-out ${
isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
}`}>
{/* Main card */}
<div className={`rounded-xl border shadow-2xl backdrop-blur-xl ${colors.bg} ${colors.border}`}>
{/* Header */}
<div className="p-6 border-b border-current/10">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 p-2 rounded-full bg-white/50 dark:bg-black/20">
{getIcon()}
</div>
<div className="flex-1">
<h3 className={`text-lg font-semibold ${colors.text}`}>
{getActionTitle()}
</h3>
<p className={`text-sm mt-1 ${colors.accent}`}>
Detected: "{match.matchedText}"
</p>
</div>
<div className={`text-xs px-2 py-1 rounded-full bg-white/30 dark:bg-black/20 ${colors.text}`}>
{Math.round(match.confidence * 100)}% confident
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className={`w-4 h-4 mt-0.5 flex-shrink-0 ${colors.accent}`} />
<p className={`text-sm leading-relaxed ${colors.text}`}>
{getActionDescription()}
</p>
</div>
{/* Redirect input */}
{showRedirectInput && (
<div className="mb-4 space-y-2">
<label className={`text-sm font-medium ${colors.text}`}>
New task or instruction:
</label>
<textarea
value={redirectMessage}
onChange={(e) => setRedirectMessage(e.target.value)}
placeholder="Enter your new instruction..."
className={`w-full px-3 py-2 border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-current/20 bg-white/50 dark:bg-black/20 ${colors.border} ${colors.text}`}
rows={3}
autoFocus
/>
</div>
)}
{/* Confidence indicator */}
<div className="mb-4">
<div className="flex items-center justify-between text-xs mb-1">
<span className={colors.accent}>Detection Confidence</span>
<span className={colors.text}>{Math.round(match.confidence * 100)}%</span>
</div>
<div className="w-full bg-white/30 dark:bg-black/20 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-500 ${
match.confidence > 0.8 ? 'bg-green-500' :
match.confidence > 0.6 ? 'bg-amber-500' : 'bg-red-500'
}`}
style={{ width: `${match.confidence * 100}%` }}
/>
</div>
</div>
</div>
{/* Actions */}
<div className="p-6 pt-0 flex gap-3">
<Button
variant="ghost"
onClick={onCancelInterruption}
className={`flex-1 hover:bg-white/20 dark:hover:bg-black/20 ${colors.text}`}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={showRedirectInput && !redirectMessage.trim()}
className={`flex-1 bg-white/80 hover:bg-white dark:bg-white/10 dark:hover:bg-white/20 ${colors.text} font-medium shadow-md hover:shadow-lg transition-all duration-200`}
>
<Zap className="w-4 h-4 mr-2" />
{showRedirectInput ? 'Redirect' : match.keyword.action === 'stop' ? 'Stop' : 'Confirm'}
</Button>
</div>
</div>
{/* Backdrop hint */}
<div className="text-center mt-4">
<p className="text-xs text-white/60">
Click outside or press Cancel to continue current processing
</p>
</div>
</div>
</div>
);
};
export default InterruptionHandler;
+433
View File
@@ -0,0 +1,433 @@
import React, { useState } from 'react';
import { X, Clock, Send, GripVertical, Zap, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from './ui/button';
interface QueuedMessage {
id: string;
content: string;
timestamp: number;
}
interface MessageQueueProps {
queuedMessages: QueuedMessage[];
onRemoveMessage: (id: string) => void;
onClearQueue: () => void;
onStopAndSend?: (messageId: string) => void;
onEditMessage?: (messageId: string, newContent: string) => void;
onTriggerQueueProcessing?: () => void;
editingMessageIdRef?: React.MutableRefObject<string | null>;
onReorderMessages?: (reorderedMessages: QueuedMessage[]) => void;
className?: string;
isPaused?: boolean;
}
export const MessageQueue: React.FC<MessageQueueProps> = ({
queuedMessages,
onRemoveMessage,
onClearQueue,
onStopAndSend,
onEditMessage,
onTriggerQueueProcessing,
editingMessageIdRef,
onReorderMessages,
className = '',
isPaused = false,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [dragOverItem, setDragOverItem] = useState<string | null>(null);
const [hoveredMessage, setHoveredMessage] = useState<string | null>(null);
const [editingMessage, setEditingMessage] = useState<string | null>(null);
const [editContent, setEditContent] = useState<string>('');
if (queuedMessages.length === 0) {
return null;
}
const handleDragStart = (e: React.DragEvent, messageId: string) => {
setDraggedItem(messageId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', messageId);
};
const handleDragOver = (e: React.DragEvent, messageId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverItem(messageId);
};
const handleDragLeave = () => {
setDragOverItem(null);
};
const handleDrop = (e: React.DragEvent, targetMessageId: string) => {
e.preventDefault();
if (!draggedItem || !onReorderMessages) return;
const draggedIndex = queuedMessages.findIndex((msg) => msg.id === draggedItem);
const targetIndex = queuedMessages.findIndex((msg) => msg.id === targetMessageId);
if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) {
setDraggedItem(null);
setDragOverItem(null);
return;
}
const newMessages = [...queuedMessages];
const [removed] = newMessages.splice(draggedIndex, 1);
newMessages.splice(targetIndex, 0, removed);
onReorderMessages(newMessages);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
const formatTimestamp = (timestamp: number) => {
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return 'now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
return `${Math.floor(diff / 3600000)}h`;
};
const nextMessage = queuedMessages[0];
const remainingCount = queuedMessages.length - 1;
// Compact View
if (!isExpanded) {
return (
<div className={`relative ${className}`}>
{/* Compact Header */}
<div
className="flex items-center justify-between px-4 py-2.5 bg-background border-b border-border/20 cursor-pointer hover:bg-muted/30 transition-all duration-200"
onClick={() => setIsExpanded(true)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center gap-2">
{isPaused ? (
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
) : (
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
)}
<span className="text-sm font-medium text-foreground">
{isPaused ? 'Paused' : 'Next'}
</span>
</div>
{/* Next message preview */}
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground truncate" title={nextMessage.content}>
{nextMessage.content.length > 40
? `${nextMessage.content.substring(0, 40)}...`
: nextMessage.content}
</p>
</div>
{/* Queue count */}
{remainingCount > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-bgSubtle border border-borderSubtle px-2 py-1 rounded-full font-medium">
<span>+{remainingCount}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Quick Send Now button */}
{onStopAndSend && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onStopAndSend(nextMessage.id);
}}
className="h-7 px-2 text-xs text-info hover:text-info/80 hover:bg-info/10"
title="Send this message now"
>
<Send className="w-3 h-3" />
</Button>
)}
{/* Expand button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
title="Expand queue"
>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
</div>
{/* Paused state indicator */}
{isPaused && (
<div className="px-4 py-1.5 bg-amber-50/60 dark:bg-amber-900/20 border-b border-amber-200/30 dark:border-amber-800/30">
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-300">
<Zap className="w-3 h-3" />
<span>Queue paused - click "Send" or add new message to resume</span>
</div>
</div>
)}
</div>
);
}
// Expanded View
return (
<div className={`relative ${className}`}>
{/* Expanded Header */}
<div className="flex items-center justify-between px-4 py-3 bg-background border-b border-border/30">
<div className="flex items-center gap-3">
<div className="relative">
{isPaused ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<Clock className="w-4 h-4 text-amber-600 dark:text-amber-400" />
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<Sparkles className="w-4 h-4 text-info" />
</div>
)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">
{isPaused ? 'Queue Paused' : 'Message Queue'}
</span>
<span className="text-xs text-muted-foreground">
{queuedMessages.length} message{queuedMessages.length !== 1 ? 's' : ''}
{isPaused ? ' waiting' : ' queued'}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{queuedMessages.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={onClearQueue}
className="text-xs h-7 px-3 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
Clear All
</Button>
)}
{/* Collapse button */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(false)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
title="Collapse queue"
>
<ChevronUp className="w-4 h-4" />
</Button>
</div>
</div>
{/* Status Banner for Paused State */}
{isPaused && (
<div className="px-4 py-2 bg-amber-50/80 dark:bg-amber-900/20 border-b border-amber-200/50 dark:border-amber-800/50">
<div className="flex items-center gap-2 text-sm text-amber-800 dark:text-amber-200">
<Zap className="w-4 h-4" />
<span>
Queue paused by interruption. Use "Send Now" or add a new message to resume.
</span>
</div>
</div>
)}
{/* Message Bubbles */}
<div className="p-4 space-y-3 bg-background max-h-80 overflow-y-auto">
{queuedMessages.map((message, index) => (
<div
key={message.id}
className="group relative"
draggable={onReorderMessages ? true : false}
onDragStart={(e) => handleDragStart(e, message.id)}
onDragOver={(e) => handleDragOver(e, message.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, message.id)}
onDragEnd={handleDragEnd}
onMouseEnter={() => setHoveredMessage(message.id)}
onMouseLeave={() => setHoveredMessage(null)}
>
{/* Main message bubble */}
<div
className={`relative flex items-center gap-3 rounded-xl px-4 py-3 border transition-all duration-300 ease-out ${
draggedItem === message.id
? 'bg-info/20 border-info opacity-60 scale-105 shadow-lg rotate-2'
: dragOverItem === message.id
? 'bg-green-100/80 border-green-400 shadow-lg dark:bg-green-950/50 dark:border-green-600 scale-102'
: hoveredMessage === message.id
? 'bg-muted/90 border-border shadow-md scale-101'
: 'bg-muted/60 hover:bg-muted/80 border-border/60 hover:border-border dark:border-border/60 dark:hover:border-border'
} backdrop-blur-sm`}
>
{/* Priority indicator */}
<div className="flex items-center gap-2">
<div
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold transition-colors ${
index === 0
? 'bg-blue-500 text-white shadow-md'
: 'bg-muted text-muted-foreground'
}`}
>
{index + 1}
</div>
{/* Drag handle */}
{onReorderMessages && (
<div
className={`opacity-0 group-hover:opacity-60 hover:opacity-100 transition-all duration-200 cursor-grab active:cursor-grabbing ${
hoveredMessage === message.id ? 'opacity-40' : ''
}`}
>
<GripVertical className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</div>
)}
</div>
{/* Message content */}
<div className="flex-1 min-w-0">
{editingMessage === message.id ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full text-sm bg-background border border-border rounded-md px-2 py-1 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
rows={Math.min(Math.ceil(editContent.length / 60), 4)}
autoFocus
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
if (onEditMessage) {
onEditMessage(message.id, editContent);
}
setEditingMessage(null);
if (editingMessageIdRef) editingMessageIdRef.current = null;
// Trigger queue processing if system is ready
if (onTriggerQueueProcessing) {
setTimeout(onTriggerQueueProcessing, 100);
}
setEditContent('');
}}
className="h-6 px-2 text-xs"
>
Save
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingMessage(null);
if (editingMessageIdRef) editingMessageIdRef.current = null;
// Trigger queue processing if system is ready
if (onTriggerQueueProcessing) {
setTimeout(onTriggerQueueProcessing, 100);
}
setEditContent('');
}}
className="h-6 px-2 text-xs"
>
Cancel
</Button>
</div>
</div>
) : (
<p
className="text-sm text-foreground leading-relaxed cursor-pointer hover:bg-muted/30 rounded px-1 py-0.5 transition-colors"
title={`${message.content} (Click to edit)`}
onClick={() => {
setEditingMessage(message.id);
if (editingMessageIdRef) editingMessageIdRef.current = message.id;
setEditContent(message.content);
}}
>
{message.content.length > 80
? `${message.content.substring(0, 80)}...`
: message.content}
</p>
)}
</div>
{/* Right side actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-muted-foreground font-mono">
{formatTimestamp(message.timestamp)}
</span>
{/* Send Now button - inline */}
{onStopAndSend && (
<Button
variant="ghost"
size="sm"
onClick={() => onStopAndSend(message.id)}
disabled={editingMessage === message.id}
className={`h-7 w-7 p-0 rounded-full transition-all duration-200 ${
editingMessage === message.id
? 'opacity-30 cursor-not-allowed'
: 'hover:bg-muted/50'
}`}
title={
editingMessage === message.id
? 'Cannot send while editing'
: 'Stop current processing and send this message now'
}
>
<Send className="w-3 h-3" />
</Button>
)}
{/* Remove button */}
<Button
variant="ghost"
size="sm"
onClick={() => onRemoveMessage(message.id)}
className="opacity-60 hover:opacity-100 transition-opacity h-6 w-6 p-0 hover:bg-destructive/20 hover:text-destructive rounded-full"
title="Remove this message from queue"
>
<X className="w-3 h-3" />
</Button>
</div>
</div>
{/* Drop indicator with enhanced visuals */}
{dragOverItem === message.id && draggedItem !== message.id && (
<div className="absolute inset-0 border-2 border-green-400 rounded-xl pointer-events-none animate-pulse bg-green-100/20 dark:bg-green-900/20" />
)}
{/* Next up indicator */}
{index === 0 && !isPaused && (
<div className="absolute -top-2 -right-2 bg-blue-500 text-white text-xs px-2 py-1 rounded-full font-medium shadow-md">
Next
</div>
)}
</div>
))}
</div>
{/* Drag instructions */}
{onReorderMessages && queuedMessages.length > 1 && (
<div className="px-4 pb-3 text-xs text-muted-foreground flex items-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
<GripVertical className="w-3 h-3" />
<span>Drag messages to reorder priority</span>
</div>
)}
</div>
);
};
export default MessageQueue;
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
import { cn } from '../../utils';
interface PillProps {
children: React.ReactNode;
className?: string;
variant?: 'default' | 'glass' | 'solid' | 'gradient' | 'glow';
size?: 'xs' | 'sm' | 'md' | 'lg';
color?: 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'slate';
onClick?: () => void;
disabled?: boolean;
animated?: boolean;
}
export function Pill({
children,
className,
variant = 'glass',
size = 'md',
color = 'blue',
onClick,
disabled = false,
animated = false,
}: PillProps) {
const baseStyles = 'inline-flex items-center justify-center rounded-full transition-all duration-300 ease-out font-medium';
const variants = {
default: 'bg-background border border-border hover:bg-muted/50',
glass: 'bg-white/10 dark:bg-black/10 backdrop-blur-xl border border-white/20 dark:border-white/10 shadow-lg shadow-black/5 dark:shadow-black/20 hover:bg-white/15 dark:hover:bg-black/15 hover:shadow-xl',
solid: 'bg-background border border-border shadow-md hover:shadow-lg hover:scale-105',
gradient: 'bg-gradient-to-r shadow-lg hover:shadow-xl hover:scale-105 border-0',
glow: 'shadow-lg hover:shadow-xl hover:scale-105 border-0',
};
const colors = {
blue: {
gradient: 'from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white',
glow: 'bg-blue-500 hover:bg-blue-600 text-white shadow-blue-500/25 hover:shadow-blue-500/40',
glass: 'text-blue-700 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-200',
},
green: {
gradient: 'from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white',
glow: 'bg-green-500 hover:bg-green-600 text-white shadow-green-500/25 hover:shadow-green-500/40',
glass: 'text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200',
},
amber: {
gradient: 'from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white',
glow: 'bg-amber-500 hover:bg-amber-600 text-white shadow-amber-500/25 hover:shadow-amber-500/40',
glass: 'text-amber-700 dark:text-amber-300 hover:text-amber-800 dark:hover:text-amber-200',
},
red: {
gradient: 'from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white',
glow: 'bg-red-500 hover:bg-red-600 text-white shadow-red-500/25 hover:shadow-red-500/40',
glass: 'text-red-700 dark:text-red-300 hover:text-red-800 dark:hover:text-red-200',
},
purple: {
gradient: 'from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white',
glow: 'bg-purple-500 hover:bg-purple-600 text-white shadow-purple-500/25 hover:shadow-purple-500/40',
glass: 'text-purple-700 dark:text-purple-300 hover:text-purple-800 dark:hover:text-purple-200',
},
slate: {
gradient: 'from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white',
glow: 'bg-slate-500 hover:bg-slate-600 text-white shadow-slate-500/25 hover:shadow-slate-500/40',
glass: 'text-slate-700 dark:text-slate-300 hover:text-slate-800 dark:hover:text-slate-200',
},
};
const sizes = {
xs: 'px-2 py-1 text-xs gap-1',
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-6 py-3 text-base gap-3',
};
const animatedStyles = animated ? 'animate-pulse' : '';
const disabledStyles = disabled
? 'opacity-50 cursor-not-allowed pointer-events-none'
: onClick
? 'cursor-pointer hover:scale-105 active:scale-95'
: '';
const colorStyles = variant === 'gradient'
? colors[color].gradient
: variant === 'glow'
? colors[color].glow
: variant === 'glass'
? colors[color].glass
: '';
return (
<div
className={cn(
baseStyles,
variants[variant],
sizes[size],
colorStyles,
disabledStyles,
animatedStyles,
className
)}
onClick={onClick && !disabled ? onClick : undefined}
>
{children}
</div>
);
}
export default Pill;
@@ -0,0 +1,225 @@
import { describe, it, expect } from 'vitest';
import {
detectInterruption,
isInterruptionCommand,
getInterruptionMessage,
INTERRUPTION_KEYWORDS,
InterruptionMatch,
} from './interruptionDetector';
describe('interruptionDetector', () => {
describe('detectInterruption', () => {
describe('exact matches (confidence: 1.0)', () => {
it('detects exact stop variations', () => {
const result = detectInterruption('stop');
expect(result).not.toBeNull();
expect(result?.confidence).toBe(1.0);
expect(result?.keyword.action).toBe('stop');
expect(result?.shouldInterrupt).toBe(true);
});
it('detects exact wait variations', () => {
const result = detectInterruption('pause');
expect(result).not.toBeNull();
expect(result?.confidence).toBe(1.0);
expect(result?.keyword.action).toBe('pause');
expect(result?.shouldInterrupt).toBe(true);
});
it('detects exact redirect variations', () => {
const result = detectInterruption('actually');
expect(result).not.toBeNull();
expect(result?.confidence).toBe(1.0);
expect(result?.keyword.action).toBe('redirect');
expect(result?.shouldInterrupt).toBe(true);
});
it('is case insensitive', () => {
expect(detectInterruption('STOP')?.confidence).toBe(1.0);
expect(detectInterruption('Stop')?.confidence).toBe(1.0);
expect(detectInterruption('sToP')?.confidence).toBe(1.0);
});
});
describe('beginning matches (confidence: 0.9)', () => {
it('detects variations at start with space', () => {
const result = detectInterruption('stop doing that');
expect(result).not.toBeNull();
expect(result?.confidence).toBe(0.9);
expect(result?.shouldInterrupt).toBe(true);
});
it('detects variations at start with comma', () => {
const result = detectInterruption('wait, I need to think');
expect(result).not.toBeNull();
expect(result?.confidence).toBe(0.9);
expect(result?.shouldInterrupt).toBe(true);
});
it('detects "never mind" at the beginning', () => {
const result = detectInterruption('never mind, forget it');
expect(result).not.toBeNull();
expect(result?.confidence).toBe(0.9);
expect(result?.keyword.action).toBe('stop');
});
});
describe('contained matches in short inputs (confidence: 0.7)', () => {
it('detects keywords in short messages', () => {
const result = detectInterruption('oh wait please');
expect(result).not.toBeNull();
expect(result?.confidence).toBe(0.7);
expect(result?.matchedText).toBe('wait');
});
it('only interrupts high priority keywords when confidence is 0.7', () => {
// High priority - should interrupt
const highPriority = detectInterruption('oh stop please');
expect(highPriority?.shouldInterrupt).toBe(true);
// Medium priority - should not interrupt at 0.7 confidence
// Note: "oh actually wait" will match "wait" (high priority) first, so let's use a different example
const mediumPriority = detectInterruption('oh actually');
expect(mediumPriority?.confidence).toBe(0.7);
expect(mediumPriority?.shouldInterrupt).toBe(false);
});
it('detects contained keywords in short inputs', () => {
// The implementation actually DOES match keywords contained in short inputs
// This is by design - it uses .includes() for short messages
const result = detectInterruption('unstoppable');
expect(result).not.toBeNull();
expect(result?.matchedText).toBe('stop');
expect(result?.confidence).toBe(0.7);
// Words that don't contain any keyword variations should return null
expect(detectInterruption('continuing')).toBeNull();
expect(detectInterruption('proceeding')).toBeNull();
// Long messages should not match even if they contain keywords
expect(
detectInterruption('this is a very long message with stop in it somewhere')
).toBeNull();
});
it('ignores keywords in long messages', () => {
const longMessage =
'This is a very long message that contains stop but should not be detected';
expect(detectInterruption(longMessage)).toBeNull();
});
});
describe('edge cases', () => {
it('detects contained keywords in short inputs', () => {
// The implementation actually DOES match keywords contained in short inputs
// This is by design - it uses .includes() for short messages
const result = detectInterruption('unstoppable');
expect(result).not.toBeNull();
expect(result?.matchedText).toBe('stop');
expect(result?.confidence).toBe(0.7);
// Words that don't contain any keyword variations should return null
expect(detectInterruption('continuing')).toBeNull();
expect(detectInterruption('proceeding')).toBeNull();
// Long messages should not match even if they contain keywords
expect(
detectInterruption('this is a very long message with stop in it somewhere')
).toBeNull();
});
it('handles multiple keyword matches by returning first match', () => {
const result = detectInterruption('stop wait');
expect(result?.matchedText).toBe('stop');
expect(result?.confidence).toBe(0.9);
});
});
});
describe('isInterruptionCommand', () => {
it('returns true for interruption commands', () => {
expect(isInterruptionCommand('stop')).toBe(true);
expect(isInterruptionCommand('wait')).toBe(true);
expect(isInterruptionCommand('halt now')).toBe(true);
});
it('returns false for non-interruption text', () => {
expect(isInterruptionCommand('continue')).toBe(false);
expect(isInterruptionCommand('hello')).toBe(false);
expect(isInterruptionCommand('')).toBe(false);
});
it('respects shouldInterrupt flag', () => {
// Medium priority keyword in short text - shouldInterrupt is false
expect(isInterruptionCommand('oh actually')).toBe(false);
// High priority keyword in short text - shouldInterrupt is true
expect(isInterruptionCommand('oh stop')).toBe(true);
});
});
describe('getInterruptionMessage', () => {
it('returns correct message for stop action', () => {
const match: InterruptionMatch = {
keyword: INTERRUPTION_KEYWORDS.find((k) => k.action === 'stop')!,
matchedText: 'stop',
confidence: 1.0,
shouldInterrupt: true,
};
expect(getInterruptionMessage(match)).toBe('Stopped processing. You said "stop".');
});
it('returns correct message for pause action', () => {
const match: InterruptionMatch = {
keyword: INTERRUPTION_KEYWORDS.find((k) => k.action === 'pause')!,
matchedText: 'wait',
confidence: 1.0,
shouldInterrupt: true,
};
expect(getInterruptionMessage(match)).toBe('Paused processing. You said "wait".');
});
it('returns correct message for redirect action', () => {
const match: InterruptionMatch = {
keyword: INTERRUPTION_KEYWORDS.find((k) => k.action === 'redirect')!,
matchedText: 'actually',
confidence: 1.0,
shouldInterrupt: true,
};
expect(getInterruptionMessage(match)).toBe('Stopping to redirect. You said "actually".');
});
it('returns default message for unknown action', () => {
const match: InterruptionMatch = {
keyword: {
keyword: 'test',
variations: ['test'],
priority: 'low',
action: 'unknown' as 'stop',
},
matchedText: 'test',
confidence: 1.0,
shouldInterrupt: true,
};
expect(getInterruptionMessage(match)).toBe('Interrupted processing. You said "test".');
});
});
describe('INTERRUPTION_KEYWORDS', () => {
it('has valid structure for all keywords', () => {
INTERRUPTION_KEYWORDS.forEach((keyword) => {
expect(keyword.keyword).toBeTruthy();
expect(keyword.variations).toBeInstanceOf(Array);
expect(keyword.variations.length).toBeGreaterThan(0);
expect(['high', 'medium', 'low']).toContain(keyword.priority);
expect(['stop', 'pause', 'redirect']).toContain(keyword.action);
});
});
it('includes the main keyword in variations', () => {
INTERRUPTION_KEYWORDS.forEach((keyword) => {
expect(keyword.variations).toContain(keyword.keyword);
});
});
});
});
@@ -0,0 +1,132 @@
/**
* Utility for detecting interruption keywords in user input
*/
export interface InterruptionKeyword {
keyword: string;
variations: string[];
priority: 'high' | 'medium' | 'low';
action: 'stop' | 'pause' | 'redirect';
}
// Define interruption keywords and their variations
export const INTERRUPTION_KEYWORDS: InterruptionKeyword[] = [
{
keyword: 'stop',
variations: ['stop', 'halt', 'cease', 'quit', 'end', 'abort', 'cancel'],
priority: 'high',
action: 'stop'
},
{
keyword: 'wait',
variations: ['wait', 'hold', 'pause', 'hold on', 'wait up', 'hold up'],
priority: 'high',
action: 'pause'
},
{
keyword: 'no',
variations: ['no', 'nope', 'nah', 'wrong', 'incorrect', 'not right'],
priority: 'medium',
action: 'stop'
},
{
keyword: 'actually',
variations: ['actually', 'instead', 'rather', 'better idea', 'change of plans'],
priority: 'medium',
action: 'redirect'
},
{
keyword: 'nevermind',
variations: ['nevermind', 'never mind', 'forget it', 'ignore that', 'disregard'],
priority: 'medium',
action: 'stop'
}
];
export interface InterruptionMatch {
keyword: InterruptionKeyword;
matchedText: string;
confidence: number;
shouldInterrupt: boolean;
}
/**
* Analyzes input text for interruption keywords
*/
export function detectInterruption(input: string): InterruptionMatch | null {
if (!input || input.trim().length === 0) {
return null;
}
const normalizedInput = input.toLowerCase().trim();
// Check for exact matches first (highest confidence)
for (const keyword of INTERRUPTION_KEYWORDS) {
for (const variation of keyword.variations) {
if (normalizedInput === variation) {
return {
keyword,
matchedText: variation,
confidence: 1.0,
shouldInterrupt: true
};
}
}
}
// Check for matches at the beginning of input (high confidence)
for (const keyword of INTERRUPTION_KEYWORDS) {
for (const variation of keyword.variations) {
if (normalizedInput.startsWith(variation + ' ') || normalizedInput.startsWith(variation + ',')) {
return {
keyword,
matchedText: variation,
confidence: 0.9,
shouldInterrupt: true
};
}
}
}
// Check for matches anywhere in short inputs (medium confidence)
if (normalizedInput.length <= 20) {
for (const keyword of INTERRUPTION_KEYWORDS) {
for (const variation of keyword.variations) {
if (normalizedInput.includes(variation)) {
return {
keyword,
matchedText: variation,
confidence: 0.7,
shouldInterrupt: keyword.priority === 'high'
};
}
}
}
}
return null;
}
/**
* Checks if input is likely an interruption command
*/
export function isInterruptionCommand(input: string): boolean {
const match = detectInterruption(input);
return match?.shouldInterrupt ?? false;
}
/**
* Gets a user-friendly message for the interruption action
*/
export function getInterruptionMessage(match: InterruptionMatch): string {
switch (match.keyword.action) {
case 'stop':
return `Stopped processing. You said "${match.matchedText}".`;
case 'pause':
return `Paused processing. You said "${match.matchedText}".`;
case 'redirect':
return `Stopping to redirect. You said "${match.matchedText}".`;
default:
return `Interrupted processing. You said "${match.matchedText}".`;
}
}