Files
Codewalkers/apps/web/src/components/chat/ChatSlideOver.tsx
Lukas May 354950bb8a fix: Retry sends message with retry flag to avoid duplicate storage
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.
2026-03-04 11:25:43 +01:00

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>
</>
);
}