feat: add chat components from jarvis frontend
- Migrated Chat.tsx with message handling and UI structure - Migrated ChatInput.tsx with character limits and keyboard shortcuts - Migrated MessageList.tsx with thinking/reasoning display - Migrated ConversationSidebar.tsx (simplified placeholder) - Migrated BackendStatusBanner.tsx (simplified placeholder) - Created components/chat/index.ts barrel export - Created app/chat/page.tsx placeholder route These components are adapted from jarvis-fe but not yet fully functional: - API calls placeholder (need to wire up /api/brain/query) - Auth hooks stubbed (need useAuth implementation) - Project/conversation hooks stubbed (need implementation) - Imports changed from @jarvis/* to @mosaic/* Next steps: - Implement missing hooks (useAuth, useProjects, useConversations, useApi) - Wire up backend API endpoints - Add proper TypeScript types - Implement full conversation management
This commit is contained in:
319
apps/web/src/components/chat/MessageList.tsx
Normal file
319
apps/web/src/components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Message } from "./Chat";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
loadingQuip?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse thinking content from message.
|
||||
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
||||
*/
|
||||
function parseThinking(content: string): { thinking: string | null; response: string } {
|
||||
// Match <thinking>...</thinking> or <think>...</think> blocks
|
||||
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||
const matches = content.match(thinkingRegex);
|
||||
|
||||
if (!matches) {
|
||||
return { thinking: null, response: content };
|
||||
}
|
||||
|
||||
// Extract thinking content
|
||||
let thinking = "";
|
||||
for (const match of matches) {
|
||||
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
||||
thinking += innerContent.trim() + "\n";
|
||||
}
|
||||
|
||||
// Remove thinking blocks from response
|
||||
const response = content.replace(thinkingRegex, "").trim();
|
||||
|
||||
return {
|
||||
thinking: thinking.trim() || null,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isLoading, loadingQuip }: MessageListProps) {
|
||||
return (
|
||||
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{isLoading && <LoadingIndicator quip={loadingQuip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
const isUser = message.role === "user";
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
|
||||
// Parse thinking from content (or use pre-parsed thinking field)
|
||||
const { thinking, response } = message.thinking
|
||||
? { thinking: message.thinking, response: message.content }
|
||||
: parseThinking(message.content);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(response);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}, [response]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex gap-4 message-animate ${isUser ? "flex-row-reverse" : ""}`}
|
||||
role="article"
|
||||
aria-label={`${isUser ? "Your" : "AI Assistant"} message`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor: isUser
|
||||
? "rgb(var(--surface-2))"
|
||||
: "rgb(var(--accent-primary))",
|
||||
color: isUser ? "rgb(var(--text-secondary))" : "white",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isUser ? "You" : "AI"}
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className={`flex max-w-[85%] flex-col gap-1.5 ${isUser ? "items-end" : "items-start"}`}>
|
||||
{/* Message Header */}
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs ${isUser ? "flex-row-reverse" : ""}`}
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||
{isUser ? "You" : "AI Assistant"}
|
||||
</span>
|
||||
{/* Model indicator for assistant messages */}
|
||||
{!isUser && message.model && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-2))",
|
||||
color: "rgb(var(--text-tertiary))",
|
||||
}}
|
||||
title={message.provider ? `Provider: ${message.provider}` : undefined}
|
||||
>
|
||||
{message.model}
|
||||
</span>
|
||||
)}
|
||||
{/* Token usage indicator for assistant messages */}
|
||||
{!isUser && message.totalTokens !== undefined && message.totalTokens > 0 && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-2))",
|
||||
color: "rgb(var(--text-muted))",
|
||||
}}
|
||||
title={`Prompt: ${message.promptTokens?.toLocaleString() || 0} tokens, Completion: ${message.completionTokens?.toLocaleString() || 0} tokens`}
|
||||
>
|
||||
{formatTokenCount(message.totalTokens)} tokens
|
||||
</span>
|
||||
)}
|
||||
<span aria-label={`Sent at ${formatTime(message.createdAt)}`}>
|
||||
{formatTime(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Thinking Section - Collapsible */}
|
||||
{thinking && !isUser && (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
border: "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setThinkingExpanded(!thinkingExpanded)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
style={{ color: "rgb(var(--text-secondary))" }}
|
||||
aria-expanded={thinkingExpanded}
|
||||
>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform duration-200 ${thinkingExpanded ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span>Thinking</span>
|
||||
<span
|
||||
className="ml-auto text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
{thinkingExpanded ? "Hide" : "Show"} reasoning
|
||||
</span>
|
||||
</button>
|
||||
{thinkingExpanded && (
|
||||
<div
|
||||
className="px-3 pb-3 text-xs leading-relaxed whitespace-pre-wrap font-mono"
|
||||
style={{
|
||||
color: "rgb(var(--text-secondary))",
|
||||
borderTop: "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
{thinking}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body */}
|
||||
<div
|
||||
className="relative rounded-lg px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: isUser
|
||||
? "rgb(var(--accent-primary))"
|
||||
: "rgb(var(--surface-0))",
|
||||
color: isUser ? "white" : "rgb(var(--text-primary))",
|
||||
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{response}
|
||||
</p>
|
||||
|
||||
{/* Copy Button - appears on hover */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
||||
}}
|
||||
aria-label={copied ? "Copied!" : "Copy message"}
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIndicator({ quip }: { quip?: string | null }) {
|
||||
return (
|
||||
<div className="flex gap-4 message-animate" role="status" aria-label="AI is typing">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
|
||||
style={{ backgroundColor: "rgb(var(--accent-primary))" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-white">AI</span>
|
||||
</div>
|
||||
|
||||
{/* Loading Content */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||
AI Assistant
|
||||
</span>
|
||||
<span>thinking...</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary))",
|
||||
animationDelay: "0ms",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary))",
|
||||
animationDelay: "150ms",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary))",
|
||||
animationDelay: "300ms",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{quip && (
|
||||
<span
|
||||
className="text-sm italic animate-fade-in"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
{quip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
} else if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user