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:
@@ -27,6 +27,14 @@ import { COST_TRACKING_ENABLED } from '../updates';
|
|||||||
import { CostTracker } from './bottom_menu/CostTracker';
|
import { CostTracker } from './bottom_menu/CostTracker';
|
||||||
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
|
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
|
||||||
import { Recipe } from '../recipe';
|
import { Recipe } from '../recipe';
|
||||||
|
import MessageQueue from './MessageQueue';
|
||||||
|
import { detectInterruption } from '../utils/interruptionDetector';
|
||||||
|
|
||||||
|
interface QueuedMessage {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface PastedImage {
|
interface PastedImage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -106,6 +114,14 @@ export default function ChatInput({
|
|||||||
|
|
||||||
// Derived state - chatState != Idle means we're in some form of loading state
|
// Derived state - chatState != Idle means we're in some form of loading state
|
||||||
const isLoading = chatState !== ChatState.Idle;
|
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 { alerts, addAlert, clearAlerts } = useAlerts();
|
||||||
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
|
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
|
||||||
null
|
null
|
||||||
@@ -126,6 +142,73 @@ export default function ChatInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Debug logging removed - draft functionality is working correctly
|
// Debug logging removed - draft functionality is working correctly
|
||||||
}, [chatContext?.contextKey, chatContext?.draft, chatContext]);
|
}, [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<{
|
const [mentionPopover, setMentionPopover] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
position: { x: number; y: number };
|
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 performSubmit = () => {
|
||||||
const validPastedImageFilesPaths = pastedImages
|
const validPastedImageFilesPaths = pastedImages
|
||||||
.filter((img) => img.filePath && !img.error && !img.isLoading)
|
.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
|
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('');
|
setDisplayValue('');
|
||||||
setValue('');
|
setValue('');
|
||||||
setPastedImages([]);
|
setPastedImages([]);
|
||||||
@@ -892,6 +1034,12 @@ export default function ChatInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
|
// Handle interruption and queue logic
|
||||||
|
if (handleInterruptionAndQueue()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
!isLoadingCompaction &&
|
!isLoadingCompaction &&
|
||||||
@@ -956,6 +1104,74 @@ export default function ChatInput({
|
|||||||
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
|
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
|
||||||
const isAnyDroppedFileLoading = allDroppedFiles.some((file) => file.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col relative h-auto p-4 transition-colors ${
|
className={`flex flex-col relative h-auto p-4 transition-colors ${
|
||||||
@@ -969,6 +1185,21 @@ export default function ChatInput({
|
|||||||
onDrop={handleLocalDrop}
|
onDrop={handleLocalDrop}
|
||||||
onDragOver={handleLocalDragOver}
|
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 */}
|
{/* Input row with inline action buttons wrapped in form */}
|
||||||
<form onSubmit={onFormSubmit} className="relative flex items-end">
|
<form onSubmit={onFormSubmit} className="relative flex items-end">
|
||||||
<div className="relative flex-1">
|
<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;
|
||||||
@@ -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;
|
||||||
@@ -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}".`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user