import React, { useEffect, useRef, useState } from 'react';
import { AlertCircle, Bot, Clock3, Copy, RotateCcw, User } from 'lucide-react';
import { ProposalChatMessage } from '../api/types';
import { ToolCallIndicator } from './ToolCallIndicator';
interface ChatMessageListProps {
messages: ProposalChatMessage[];
isAgentResponding?: boolean;
onExamplePromptSelect?: (prompt: string) => void;
onRetryMessage?: (messageId: string) => void;
}
const AUTO_SCROLL_THRESHOLD_PX = 100;
const EXAMPLE_PROMPTS = [
'Summarize the current proposal and open risks',
'What implementation tasks should we tackle first?',
'Draft acceptance criteria for this change',
];
function copyTextToClipboard(text: string): void {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
return;
}
void navigator.clipboard.writeText(text);
}
function formatRelativeTime(timestamp: string): string {
const target = new Date(timestamp).getTime();
if (Number.isNaN(target)) {
return 'time unavailable';
}
const diffSeconds = Math.round((target - Date.now()) / 1000);
const absSeconds = Math.abs(diffSeconds);
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
if (absSeconds < 60) {
return rtf.format(diffSeconds, 'second');
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return rtf.format(diffMinutes, 'minute');
}
const diffHours = Math.round(diffSeconds / 3600);
if (Math.abs(diffHours) < 24) {
return rtf.format(diffHours, 'hour');
}
const diffDays = Math.round(diffSeconds / 86400);
return rtf.format(diffDays, 'day');
}
function renderCodeBlock(code: string, language: string | null, key: string): React.ReactNode {
return (
<div key={key} className="my-2 overflow-hidden rounded border border-[#27272a] bg-[#18181b]">
<div className="flex items-center justify-between border-b border-[#27272a] px-2 py-1 text-[11px] text-[#a1a1aa]">
<span>{language ?? 'code'}</span>
<button
type="button"
onClick={() => copyTextToClipboard(code)}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[#d4d4d8] transition hover:bg-[#27272a]"
title="Copy code"
aria-label="Copy code"
>
<Copy className="size-3" />
<span>Copy</span>
</button>
</div>
<pre className="overflow-x-auto p-2 font-mono text-xs text-[#d4d4d8]">
<code>{code}</code>
</pre>
</div>
);
}
function renderInlineMarkdown(text: string): React.ReactNode {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
parts.push(
<code key={key++} className="rounded bg-border px-1 py-0.5 font-mono text-[0.85em] text-accent">
{codeMatch[1]}
</code>,
);
remaining = remaining.slice(codeMatch[0].length);
continue;
}
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
if (boldMatch) {
parts.push(<strong key={key++}>{boldMatch[1]}</strong>);
remaining = remaining.slice(boldMatch[0].length);
continue;
}
const linkMatch = remaining.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/);
if (linkMatch) {
parts.push(
<a
key={key++}
href={linkMatch[2]}
target="_blank"
rel="noopener noreferrer"
className="text-[#818cf8] underline underline-offset-2 hover:text-[#a5b4fc]"
>
{linkMatch[1]}
</a>,
);
remaining = remaining.slice(linkMatch[0].length);
continue;
}
const nextSpecial = remaining.search(/[`*\[]/);
if (nextSpecial === -1) {
parts.push(remaining);
break;
}
if (nextSpecial === 0) {
parts.push(remaining[0]);
remaining = remaining.slice(1);
continue;
}
parts.push(remaining.slice(0, nextSpecial));
remaining = remaining.slice(nextSpecial);
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
function renderMarkdownSimple(content: string): React.ReactNode {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
let inCodeBlock = false;
let codeLines: string[] = [];
let codeLanguage: string | null = null;
let codeKey = 0;
let currentUlItems: string[] = [];
let currentOlItems: string[] = [];
const flushUnorderedList = () => {
if (currentUlItems.length === 0) {
return;
}
elements.push(
<ul key={`ul-${elements.length}`} className="my-1 list-disc space-y-1 pl-6">
{currentUlItems.map((item, idx) => (
<li key={`ul-item-${idx}`} className="break-words">
{renderInlineMarkdown(item)}
</li>
))}
</ul>,
);
currentUlItems = [];
};
const flushOrderedList = () => {
if (currentOlItems.length === 0) {
return;
}
elements.push(
<ol key={`ol-${elements.length}`} className="my-1 list-decimal space-y-1 pl-6">
{currentOlItems.map((item, idx) => (
<li key={`ol-item-${idx}`} className="break-words">
{renderInlineMarkdown(item)}
</li>
))}
</ol>,
);
currentOlItems = [];
};
const flushCodeBlock = () => {
if (!inCodeBlock) {
return;
}
elements.push(renderCodeBlock(codeLines.join('\n'), codeLanguage, `code-${codeKey++}`));
inCodeBlock = false;
codeLines = [];
codeLanguage = null;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('```')) {
flushUnorderedList();
flushOrderedList();
if (inCodeBlock) {
flushCodeBlock();
} else {
inCodeBlock = true;
codeLanguage = line.slice(3).trim() || null;
}
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
if (headingMatch) {
flushUnorderedList();
flushOrderedList();
const level = headingMatch[1].length;
const text = headingMatch[2];
if (level === 1) {
elements.push(
<h1 key={`line-${i}`} className="mt-2 text-xl font-semibold text-white">
{renderInlineMarkdown(text)}
</h1>,
);
} else if (level === 2) {
elements.push(
<h2 key={`line-${i}`} className="mt-2 text-lg font-semibold text-white">
{renderInlineMarkdown(text)}
</h2>,
);
} else {
elements.push(
<h3 key={`line-${i}`} className="mt-2 text-base font-semibold text-[#e4e4e7]">
{renderInlineMarkdown(text)}
</h3>,
);
}
continue;
}
const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
if (unorderedMatch) {
flushOrderedList();
currentUlItems.push(unorderedMatch[1]);
continue;
}
const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
if (orderedMatch) {
flushUnorderedList();
currentOlItems.push(orderedMatch[1]);
continue;
}
if (/^\s*---\s*$/.test(line)) {
flushUnorderedList();
flushOrderedList();
elements.push(<hr key={`line-${i}`} className="my-3 border-[#3f3f46]" />);
continue;
}
flushUnorderedList();
flushOrderedList();
elements.push(
<p key={`line-${i}`} className="min-h-[1.25em] whitespace-pre-wrap break-words">
{renderInlineMarkdown(line)}
</p>,
);
}
flushCodeBlock();
flushUnorderedList();
flushOrderedList();
return elements;
}
function TypingIndicator() {
return (
<div className="flex items-start gap-3" data-testid="typing-indicator">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#1e1b4b]">
<Bot className="size-4 text-[#a5b4fc]" />
</div>
<div className="min-w-0 rounded-lg bg-[#18181b] px-3 py-2 text-sm text-[#d4d4d8]">
<div className="flex items-center gap-2">
<span className="text-[#a1a1aa]">Agent is thinking...</span>
<span className="inline-flex gap-1" aria-hidden="true">
<span className="size-1.5 animate-bounce rounded-full bg-[#6366f1] [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[#6366f1] [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[#6366f1]" />
</span>
</div>
</div>
</div>
);
}
export function ChatMessageList({
messages,
isAgentResponding = false,
onExamplePromptSelect,
onRetryMessage,
}: ChatMessageListProps) {
const scrollerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [hasUnreadMessages, setHasUnreadMessages] = useState(false);
const updateNearBottom = () => {
const scroller = scrollerRef.current;
if (!scroller) {
return;
}
const distanceToBottom = scroller.scrollHeight - (scroller.scrollTop + scroller.clientHeight);
const nearBottom = distanceToBottom <= AUTO_SCROLL_THRESHOLD_PX;
setIsNearBottom(nearBottom);
if (nearBottom) {
setHasUnreadMessages(false);
}
};
useEffect(() => {
updateNearBottom();
}, []);
useEffect(() => {
if (isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
return;
}
if (messages.length > 0) {
setHasUnreadMessages(true);
}
}, [messages, isNearBottom]);
const showTypingIndicator =
isAgentResponding && (messages.length === 0 || messages[messages.length - 1].role !== 'assistant');
return (
<div className="relative flex-1 overflow-hidden">
<div
ref={scrollerRef}
className="h-full flex-1 space-y-4 overflow-y-auto p-4"
data-testid="chat-scroll-container"
onScroll={updateNearBottom}
>
{messages.length === 0 && (
<div className="flex flex-1 items-center justify-center py-16">
<div className="flex max-w-md flex-col items-center gap-3 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-accent/20">
<Bot className="size-5 text-accent" />
</div>
<p className="text-sm font-medium text-text">Start a conversation with the agent</p>
<p className="text-xs text-text-subtle">Try one of these prompts to get started:</p>
<div className="flex flex-wrap items-center justify-center gap-2">
{EXAMPLE_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
className="rounded-full border border-border bg-surface px-3 py-1 text-xs text-text-muted transition-colors hover:border-border-hover hover:text-text"
onClick={() => onExamplePromptSelect?.(prompt)}
>
{prompt}
</button>
))}
</div>
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} onRetryMessage={onRetryMessage} />
))}
{showTypingIndicator && <TypingIndicator />}
<div ref={bottomRef} />
</div>
{hasUnreadMessages && (
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">
<button
type="button"
className="pointer-events-auto rounded-full border border-[#3f3f46] bg-[#18181b] px-3 py-1 text-xs font-medium text-[#e4e4e7] shadow-lg transition-colors hover:border-[#6366f1] hover:text-white"
onClick={() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
setHasUnreadMessages(false);
}}
>
↓ New messages
</button>
</div>
)}
</div>
);
}
function MessageBubble({
message,
onRetryMessage,
}: {
message: ProposalChatMessage;
onRetryMessage?: (messageId: string) => void;
}) {
const isUser = message.role === 'user';
const isPending = isUser && message.sendStatus === 'pending';
const isFailed = isUser && message.sendStatus === 'failed';
return (
<div className={`group flex items-start gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
<div
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${
isUser ? 'bg-border' : 'bg-accent/20'
}`}
>
{isUser ? <User className="size-4 text-text-muted" /> : <Bot className="size-4 text-accent" />}
</div>
<div
className={`relative min-w-0 max-w-[80%] space-y-2 rounded-lg border px-3 py-2 text-sm ${
isUser
? isFailed
? 'border-[#dc2626] bg-[#450a0a]/40 text-[#fecaca]'
: isPending
? 'border-[#3f3f46] bg-[#18181b] text-[#d4d4d8]'
: 'border-transparent bg-[#1e1b4b]/60 text-[#e0e7ff]'
: 'border-transparent bg-[#18181b] text-[#d4d4d8]'
}`}
>
{!isUser && (
<button
type="button"
onClick={() => copyTextToClipboard(message.content)}
className="absolute right-2 top-2 inline-flex items-center gap-1 rounded bg-[#27272a]/90 px-1.5 py-1 text-xs text-[#d4d4d8] opacity-0 transition hover:bg-[#3f3f46] group-hover:opacity-100"
title="Copy message"
aria-label="Copy message"
>
<Copy className="size-3" />
<span>Copy</span>
</button>
)}
<div>{renderMarkdownSimple(message.content)}</div>
{isPending && (
<div className="inline-flex items-center gap-1 text-[11px] text-[#a1a1aa]" data-testid="message-pending-indicator">
<Clock3 className="size-3" />
<span>Queued (will send on reconnect)</span>
</div>
)}
{isFailed && (
<div className="flex items-center gap-2" data-testid="message-failed-indicator">
<span className="inline-flex items-center gap-1 text-[11px] text-[#fca5a5]">
<AlertCircle className="size-3" />
Failed to send
</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-[#dc2626] px-2 py-0.5 text-[11px] text-[#fecaca] transition-colors hover:bg-[#7f1d1d]/60"
onClick={() => onRetryMessage?.(message.id)}
>
<RotateCcw className="size-3" />
Retry
</button>
</div>
)}
<p className="text-[11px] text-[#71717a] opacity-0 transition group-hover:opacity-100" title={message.timestamp}>
{formatRelativeTime(message.timestamp)}
</p>
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{message.tool_calls.map((tc) => (
<ToolCallIndicator key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
</div>
);
}