Added retry:true flag to sendChatMessage input. Server skips storing the user message when retry is set. Frontend uses a dedicated retryLastMessage function that skips the optimistic message add.
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { X, Loader2, AlertTriangle, RotateCcw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useChatSession } from '@/hooks/useChatSession';
|
|
import { ChatBubble } from './ChatBubble';
|
|
import { ChatInput } from './ChatInput';
|
|
|
|
export interface ChatTarget {
|
|
type: 'phase' | 'task';
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface ChatSlideOverProps {
|
|
target: ChatTarget | null;
|
|
initiativeId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ChatSlideOver({ target, initiativeId, onClose }: ChatSlideOverProps) {
|
|
return (
|
|
<AnimatePresence>
|
|
{target && (
|
|
<ChatSlideOverInner
|
|
key={`${target.type}-${target.id}`}
|
|
target={target}
|
|
initiativeId={initiativeId}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
function ChatSlideOverInner({
|
|
target,
|
|
initiativeId,
|
|
onClose,
|
|
}: {
|
|
target: ChatTarget;
|
|
initiativeId: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const { messages, agentStatus, agentError, sendMessage, retryLastMessage, closeSession, isSending } =
|
|
useChatSession(target.type, target.id, initiativeId);
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Auto-scroll on new messages
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (el) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}, [messages.length]);
|
|
|
|
// Escape to close
|
|
useEffect(() => {
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') onClose();
|
|
}
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => document.removeEventListener('keydown', onKeyDown);
|
|
}, [onClose]);
|
|
|
|
const isAgentWorking = agentStatus === 'running' || isSending;
|
|
|
|
function handleClose() {
|
|
closeSession();
|
|
onClose();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
className="fixed inset-0 z-40 bg-background/60 backdrop-blur-[2px]"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<motion.div
|
|
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-2xl flex-col border-l border-border bg-background shadow-xl"
|
|
initial={{ x: '100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '100%' }}
|
|
transition={{ duration: 0.25, ease: [0, 0, 0.2, 1] }}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 border-b border-border px-5 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-sm font-semibold leading-snug">
|
|
Chat: {target.name}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground capitalize">{target.type}</p>
|
|
</div>
|
|
{isAgentWorking && (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Processing...
|
|
</div>
|
|
)}
|
|
<button
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
onClick={handleClose}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto py-3">
|
|
{messages.length === 0 && (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
Send a message to start refining this {target.type}
|
|
</div>
|
|
)}
|
|
{messages.map((msg) => (
|
|
<ChatBubble key={msg.id} message={msg} />
|
|
))}
|
|
{isAgentWorking && messages.length > 0 && (
|
|
<div className="flex justify-start px-4 py-1.5">
|
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Thinking...
|
|
</div>
|
|
</div>
|
|
)}
|
|
{agentStatus === 'crashed' && agentError && (
|
|
<div className="mx-4 my-2 rounded-lg border border-status-error-border bg-status-error-bg px-3 py-2.5">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-status-error-fg" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium text-status-error-fg">Agent error</p>
|
|
<p className="mt-0.5 text-xs text-status-error-fg/80 break-words">{agentError}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex justify-end">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 gap-1.5 text-xs"
|
|
onClick={retryLastMessage}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<ChatInput
|
|
onSend={sendMessage}
|
|
disabled={isAgentWorking}
|
|
placeholder={`Tell the agent what to change...`}
|
|
/>
|
|
</motion.div>
|
|
</>
|
|
);
|
|
}
|