Date: November 30, 2025 Author: Analyst Agent (Hive Mind Swarm) Phase: Phase 1 - Foundation & Chat Interface Status: Design Complete
This document provides a comprehensive frontend architecture design for Quorum's Phase 1 chat interface, built with Next.js 15, React 19, and modern streaming technologies. The architecture prioritizes real-time streaming performance, clean separation of concerns, and scalability for future debate features.
Key Technology Decisions:
- Framework: Next.js 15 App Router with React 19 Server Components
- State Management: Zustand (primary) + TanStack Query (server state)
- UI Components: shadcn/ui + Tailwind CSS
- Streaming: Server-Sent Events (SSE) via FastAPI backend
- Type Safety: TypeScript strict mode throughout
frontend/
├── app/
│ ├── (chat)/ # Chat route group
│ │ ├── layout.tsx # Chat-specific layout
│ │ ├── page.tsx # Chat interface (main page)
│ │ └── loading.tsx # Streaming skeleton
│ │
│ ├── api/ # Client-side API routes (optional)
│ │ └── health/
│ │ └── route.ts # Health check endpoint
│ │
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Landing/redirect to chat
│ ├── globals.css # Global styles + Tailwind
│ ├── providers.tsx # Client-side providers wrapper
│ └── error.tsx # Global error boundary
│
├── components/
│ ├── ui/ # shadcn/ui components
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── input.tsx
│ │ ├── scroll-area.tsx
│ │ ├── badge.tsx
│ │ ├── avatar.tsx
│ │ └── ...
│ │
│ ├── chat/
│ │ ├── ChatInterface.tsx # Main chat container
│ │ ├── MessageList.tsx # Virtualized message list
│ │ ├── MessageBubble.tsx # Individual message component
│ │ ├── StreamingMessage.tsx # Real-time streaming message
│ │ ├── MessageInput.tsx # Text input with send button
│ │ ├── ParticipantIndicator.tsx # Typing/streaming indicators
│ │ └── MessageTimestamp.tsx # Timestamp display
│ │
│ └── providers/
│ └── StreamingProvider.tsx # SSE connection context
│
├── lib/
│ ├── api/
│ │ ├── client.ts # API client configuration
│ │ └── endpoints.ts # API endpoint definitions
│ │
│ ├── streaming/
│ │ ├── sse-client.ts # SSE connection manager
│ │ └── message-buffer.ts # Message buffering logic
│ │
│ └── utils.ts # Utility functions
│
├── hooks/
│ ├── useStreamingText.ts # Custom streaming hook
│ ├── useSSEConnection.ts # SSE connection hook
│ ├── useMessageBuffer.ts # Message buffering hook
│ └── useScrollToBottom.ts # Auto-scroll behavior
│
├── stores/
│ ├── chat-store.ts # Zustand chat state
│ └── ui-store.ts # Zustand UI state
│
├── types/
│ ├── chat.ts # Chat type definitions
│ ├── message.ts # Message type definitions
│ └── api.ts # API type definitions
│
└── public/
└── avatars/ # User avatar images
app/layout.tsx (Root Layout):
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Quorum - AI Debate Platform',
description: 'Real-time multi-LLM debates',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
{children}
</Providers>
</body>
</html>
);
}app/providers.tsx (Client-Side Providers):
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 3,
refetchOnWindowFocus: false,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}app/(chat)/page.tsx (Main Chat Interface):
import { ChatInterface } from '@/components/chat/ChatInterface';
export default function ChatPage() {
return (
<main className="flex h-screen flex-col">
<ChatInterface />
</main>
);
}stores/chat-store.ts:
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
// Types
export interface Message {
id: string;
participantId: string;
participantName: string;
content: string;
timestamp: number;
isStreaming: boolean;
metadata?: {
model?: string;
tokens?: number;
};
}
export interface Participant {
id: string;
name: string;
model: string;
provider: string;
color: string;
status: 'idle' | 'typing' | 'streaming' | 'complete' | 'error';
}
interface ChatState {
// State
messages: Message[];
participants: Record<string, Participant>;
activeParticipantId: string | null;
isConnected: boolean;
connectionError: string | null;
// Actions
addMessage: (message: Message) => void;
updateMessage: (id: string, updates: Partial<Message>) => void;
updateStreamingContent: (id: string, content: string) => void;
finalizeMessage: (id: string) => void;
addParticipant: (participant: Participant) => void;
updateParticipantStatus: (id: string, status: Participant['status']) => void;
setActiveParticipant: (id: string | null) => void;
setConnectionStatus: (connected: boolean, error?: string) => void;
clearMessages: () => void;
reset: () => void;
}
// Initial state
const initialState = {
messages: [],
participants: {},
activeParticipantId: null,
isConnected: false,
connectionError: null,
};
// Store
export const useChatStore = create<ChatState>()(
devtools(
persist(
immer((set) => ({
...initialState,
addMessage: (message) =>
set((state) => {
state.messages.push(message);
}),
updateMessage: (id, updates) =>
set((state) => {
const message = state.messages.find((m) => m.id === id);
if (message) {
Object.assign(message, updates);
}
}),
updateStreamingContent: (id, content) =>
set((state) => {
const message = state.messages.find((m) => m.id === id);
if (message) {
message.content = content;
message.isStreaming = true;
}
}),
finalizeMessage: (id) =>
set((state) => {
const message = state.messages.find((m) => m.id === id);
if (message) {
message.isStreaming = false;
message.timestamp = Date.now();
}
}),
addParticipant: (participant) =>
set((state) => {
state.participants[participant.id] = participant;
}),
updateParticipantStatus: (id, status) =>
set((state) => {
if (state.participants[id]) {
state.participants[id].status = status;
}
}),
setActiveParticipant: (id) =>
set((state) => {
state.activeParticipantId = id;
}),
setConnectionStatus: (connected, error) =>
set((state) => {
state.isConnected = connected;
state.connectionError = error || null;
}),
clearMessages: () =>
set((state) => {
state.messages = [];
}),
reset: () => set(initialState),
})),
{
name: 'quorum-chat-storage',
partialize: (state) => ({
// Only persist participants, not messages
participants: state.participants,
}),
}
)
)
);
// Selectors
export const selectMessages = (state: ChatState) => state.messages;
export const selectParticipants = (state: ChatState) => state.participants;
export const selectActiveParticipant = (state: ChatState) =>
state.activeParticipantId
? state.participants[state.activeParticipantId]
: null;
export const selectIsConnected = (state: ChatState) => state.isConnected;stores/ui-store.ts:
import { create } from 'zustand';
interface UIState {
// State
isSidebarOpen: boolean;
isMessageInputFocused: boolean;
showScrollToBottom: boolean;
// Actions
toggleSidebar: () => void;
setMessageInputFocused: (focused: boolean) => void;
setShowScrollToBottom: (show: boolean) => void;
}
export const useUIStore = create<UIState>((set) => ({
isSidebarOpen: true,
isMessageInputFocused: false,
showScrollToBottom: false,
toggleSidebar: () =>
set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
setMessageInputFocused: (focused) =>
set({ isMessageInputFocused: focused }),
setShowScrollToBottom: (show) =>
set({ showScrollToBottom: show }),
}));lib/streaming/sse-client.ts:
export interface SSEConnectionOptions {
endpoint: string;
onMessage: (data: any) => void;
onError?: (error: Error) => void;
onOpen?: () => void;
onClose?: () => void;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
export class SSEClient {
private eventSource: EventSource | null = null;
private reconnectAttempts = 0;
private reconnectTimer: NodeJS.Timeout | null = null;
private options: Required<SSEConnectionOptions>;
constructor(options: SSEConnectionOptions) {
this.options = {
onError: () => {},
onOpen: () => {},
onClose: () => {},
reconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 5,
...options,
};
}
connect(): void {
try {
this.eventSource = new EventSource(this.options.endpoint);
this.eventSource.onopen = () => {
this.reconnectAttempts = 0;
this.options.onOpen();
};
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.options.onMessage(data);
} catch (error) {
console.error('Failed to parse SSE message:', error);
}
};
this.eventSource.onerror = (error) => {
this.handleError(error);
};
// Custom event listeners
this.eventSource.addEventListener('chunk', (event: any) => {
try {
const data = JSON.parse(event.data);
this.options.onMessage({ type: 'chunk', ...data });
} catch (error) {
console.error('Failed to parse chunk event:', error);
}
});
this.eventSource.addEventListener('done', () => {
this.options.onMessage({ type: 'done' });
this.disconnect();
});
} catch (error) {
this.handleError(error as Error);
}
}
private handleError(error: Error | Event): void {
const err = error instanceof Error ? error : new Error('SSE connection error');
this.options.onError(err);
if (this.options.reconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
this.reconnect();
} else {
this.disconnect();
}
}
private reconnect(): void {
this.reconnectAttempts++;
console.log(`Reconnecting... Attempt ${this.reconnectAttempts}`);
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => {
this.disconnect();
this.connect();
}, this.options.reconnectInterval * this.reconnectAttempts);
}
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
this.options.onClose();
}
}
isConnected(): boolean {
return this.eventSource?.readyState === EventSource.OPEN;
}
}hooks/useStreamingText.ts:
import { useState, useEffect, useRef } from 'react';
import { SSEClient } from '@/lib/streaming/sse-client';
export interface UseStreamingTextOptions {
endpoint: string | null;
onComplete?: (content: string) => void;
onError?: (error: Error) => void;
bufferDelay?: number;
}
export function useStreamingText(options: UseStreamingTextOptions) {
const { endpoint, onComplete, onError, bufferDelay = 50 } = options;
const [displayedText, setDisplayedText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<Error | null>(null);
const bufferRef = useRef('');
const clientRef = useRef<SSEClient | null>(null);
const updateTimerRef = useRef<NodeJS.Timeout | null>(null);
// Flush buffer to displayed text periodically
useEffect(() => {
if (!isStreaming) return;
updateTimerRef.current = setInterval(() => {
if (bufferRef.current) {
setDisplayedText(bufferRef.current);
}
}, bufferDelay);
return () => {
if (updateTimerRef.current) {
clearInterval(updateTimerRef.current);
}
};
}, [isStreaming, bufferDelay]);
// Manage SSE connection
useEffect(() => {
if (!endpoint) return;
const client = new SSEClient({
endpoint,
onMessage: (data) => {
if (data.type === 'chunk') {
bufferRef.current += data.content || '';
} else if (data.type === 'done') {
setDisplayedText(bufferRef.current);
setIsStreaming(false);
onComplete?.(bufferRef.current);
}
},
onOpen: () => {
setIsStreaming(true);
setError(null);
},
onError: (err) => {
setError(err);
setIsStreaming(false);
onError?.(err);
},
onClose: () => {
setIsStreaming(false);
},
});
client.connect();
clientRef.current = client;
return () => {
client.disconnect();
};
}, [endpoint, onComplete, onError]);
const reset = () => {
bufferRef.current = '';
setDisplayedText('');
setError(null);
};
return {
displayedText,
isStreaming,
error,
reset,
};
}components/chat/ChatInterface.tsx:
'use client';
import { useChatStore } from '@/stores/chat-store';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { ParticipantIndicator } from './ParticipantIndicator';
export function ChatInterface() {
const participants = useChatStore((state) => state.participants);
const isConnected = useChatStore((state) => state.isConnected);
const connectionError = useChatStore((state) => state.connectionError);
return (
<div className="flex h-full flex-col">
{/* Header */}
<header className="border-b bg-white p-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Quorum Chat</h1>
<div className="flex items-center gap-2">
{Object.values(participants).map((participant) => (
<ParticipantIndicator key={participant.id} participant={participant} />
))}
</div>
</div>
{!isConnected && connectionError && (
<div className="mt-2 rounded bg-red-50 p-2 text-sm text-red-600">
Connection error: {connectionError}
</div>
)}
</header>
{/* Message List */}
<MessageList />
{/* Input Area */}
<MessageInput />
</div>
);
}components/chat/MessageList.tsx:
'use client';
import { useRef, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useChatStore } from '@/stores/chat-store';
import { MessageBubble } from './MessageBubble';
import { StreamingMessage } from './StreamingMessage';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useScrollToBottom } from '@/hooks/useScrollToBottom';
export function MessageList() {
const messages = useChatStore((state) => state.messages);
const parentRef = useRef<HTMLDivElement>(null);
const { shouldAutoScroll, scrollToBottom } = useScrollToBottom(parentRef);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
overscan: 5,
});
// Auto-scroll when new messages arrive
useEffect(() => {
if (shouldAutoScroll) {
scrollToBottom();
}
}, [messages.length, shouldAutoScroll, scrollToBottom]);
return (
<ScrollArea ref={parentRef} className="flex-1 p-4">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{message.isStreaming ? (
<StreamingMessage message={message} />
) : (
<MessageBubble message={message} />
)}
</div>
);
})}
</div>
</ScrollArea>
);
}components/chat/StreamingMessage.tsx:
'use client';
import { useChatStore } from '@/stores/chat-store';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface StreamingMessageProps {
message: {
id: string;
participantId: string;
participantName: string;
content: string;
};
}
export function StreamingMessage({ message }: StreamingMessageProps) {
const participant = useChatStore(
(state) => state.participants[message.participantId]
);
if (!participant) return null;
return (
<div className="mb-4 flex items-start gap-3">
<Avatar className={cn('border-2', `border-${participant.color}-500`)}>
<AvatarFallback className={`bg-${participant.color}-100 text-${participant.color}-700`}>
{participant.name[0]}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{participant.name}</span>
<Badge variant="outline" className="text-xs">
{participant.model}
</Badge>
</div>
<div className="rounded-lg bg-gray-50 p-3">
<div className="whitespace-pre-wrap">
{message.content}
<span className="ml-1 inline-block h-4 w-0.5 animate-pulse bg-gray-900" />
</div>
</div>
</div>
</div>
);
}components/chat/MessageInput.tsx:
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Send } from 'lucide-react';
import { useChatStore } from '@/stores/chat-store';
export function MessageInput() {
const [message, setMessage] = useState('');
const isConnected = useChatStore((state) => state.isConnected);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || !isConnected) return;
// Send message to backend
try {
const response = await fetch('http://localhost:8000/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message }),
});
if (response.ok) {
setMessage('');
}
} catch (error) {
console.error('Failed to send message:', error);
}
};
return (
<footer className="border-t bg-white p-4">
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={isConnected ? "Type a message..." : "Connecting..."}
disabled={!isConnected}
className="flex-1"
/>
<Button type="submit" disabled={!message.trim() || !isConnected}>
<Send className="h-4 w-4" />
<span className="sr-only">Send message</span>
</Button>
</form>
</footer>
);
}Install via shadcn/ui CLI:
# UI primitives
npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
npx shadcn-ui@latest add card
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add avatar
npx shadcn-ui@latest add scroll-area
# Layout components
npx shadcn-ui@latest add separator
npx shadcn-ui@latest add sheet
# Feedback components
npx shadcn-ui@latest add toast
npx shadcn-ui@latest add alert
# Form components (for future settings)
npx shadcn-ui@latest add select
npx shadcn-ui@latest add switch
npx shadcn-ui@latest add dialogcomponents/ui/message-bubble.tsx (Custom):
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const messageBubbleVariants = cva(
'rounded-lg p-3 max-w-[80%]',
{
variants: {
variant: {
sent: 'bg-blue-500 text-white ml-auto',
received: 'bg-gray-100 text-gray-900',
system: 'bg-yellow-50 text-yellow-900 border border-yellow-200',
},
},
defaultVariants: {
variant: 'received',
},
}
);
export interface MessageBubbleProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof messageBubbleVariants> {}
const MessageBubble = React.forwardRef<HTMLDivElement, MessageBubbleProps>(
({ className, variant, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(messageBubbleVariants({ variant }), className)}
{...props}
/>
);
}
);
MessageBubble.displayName = 'MessageBubble';
export { MessageBubble, messageBubbleVariants };User sends message
│
▼
MessageInput component
│
▼
POST /api/chat/send
│
▼
Backend processes & starts SSE stream
│
▼
SSE Client receives chunks
│
▼
useStreamingText hook buffers content
│
▼
Zustand store updates message state
│
▼
MessageList re-renders with new content
│
▼
Auto-scroll to bottom (if user at bottom)
│
▼
Stream completes → finalizeMessage()
// stores/chat-store.ts - Connection state flow
// 1. Initial connection attempt
setConnectionStatus(false, null);
// 2. SSE client opens connection
onOpen: () => {
setConnectionStatus(true, null);
}
// 3. Error occurs
onError: (error) => {
setConnectionStatus(false, error.message);
}
// 4. Reconnection attempts
// SSEClient handles retry logic internally
// 5. Connection closed
onClose: () => {
setConnectionStatus(false, 'Connection closed');
}lib/api/client.ts:
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export const apiClient = {
async post<T>(endpoint: string, data: any): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
return response.json();
},
async get<T>(endpoint: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`);
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
return response.json();
},
getSSEEndpoint(path: string): string {
return `${API_BASE_URL}${path}`;
},
};Expected FastAPI endpoints:
# POST /api/chat/send
# Start a new chat message, returns SSE endpoint for streaming
# GET /api/chat/stream/{message_id}
# SSE endpoint for streaming LLM response
# GET /api/chat/history
# Get message history (for initial load)
# POST /api/chat/clear
# Clear chat history// app/(chat)/page.tsx
import dynamic from 'next/dynamic';
// Lazy load heavy components
const MessageList = dynamic(() => import('@/components/chat/MessageList'), {
loading: () => <MessageListSkeleton />,
ssr: false, // Client-side only
});
const MessageInput = dynamic(() => import('@/components/chat/MessageInput'));// components/chat/MessageBubble.tsx
import { memo } from 'react';
export const MessageBubble = memo(
({ message }: MessageBubbleProps) => {
// Component implementation
},
(prev, next) => {
// Only re-render if message content or id changes
return prev.message.id === next.message.id &&
prev.message.content === next.message.content;
}
);// Optimized virtualizer config for chat
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Average message height
overscan: 5, // Render 5 extra messages
scrollMargin: parentRef.current?.offsetTop ?? 0,
gap: 8, // Gap between messages
});types/chat.ts:
export type ParticipantStatus =
| 'idle'
| 'typing'
| 'streaming'
| 'complete'
| 'error';
export type MessageType = 'user' | 'assistant' | 'system';
export interface Participant {
id: string;
name: string;
model: string;
provider: 'anthropic' | 'openai' | 'google' | 'mistral';
color: string;
status: ParticipantStatus;
avatar?: string;
}
export interface Message {
id: string;
participantId: string;
participantName: string;
type: MessageType;
content: string;
timestamp: number;
isStreaming: boolean;
metadata?: MessageMetadata;
}
export interface MessageMetadata {
model?: string;
tokens?: {
prompt: number;
completion: number;
total: number;
};
duration?: number;
error?: string;
}types/api.ts:
export interface SendMessageRequest {
content: string;
participantId?: string;
}
export interface SendMessageResponse {
messageId: string;
streamEndpoint: string;
}
export interface SSEChunkData {
type: 'chunk' | 'done' | 'error';
content?: string;
error?: string;
metadata?: Record<string, any>;
}.env.local:
# Backend API
NEXT_PUBLIC_API_URL=http://localhost:8000
# Feature flags
NEXT_PUBLIC_ENABLE_VIRTUAL_SCROLL=true
NEXT_PUBLIC_MESSAGE_BUFFER_DELAY=50
# Development
NODE_ENV=developmentnext.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Optimize for production
swcMinify: true,
// Image optimization (for avatars)
images: {
domains: ['localhost'],
},
// Experimental features
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;// __tests__/components/MessageBubble.test.tsx
import { render, screen } from '@testing-library/react';
import { MessageBubble } from '@/components/chat/MessageBubble';
describe('MessageBubble', () => {
it('renders message content', () => {
const message = {
id: '1',
participantId: 'p1',
participantName: 'Claude',
content: 'Hello world',
timestamp: Date.now(),
isStreaming: false,
};
render(<MessageBubble message={message} />);
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
});// __tests__/stores/chat-store.test.ts
import { renderHook, act } from '@testing-library/react';
import { useChatStore } from '@/stores/chat-store';
describe('useChatStore', () => {
it('adds message', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.addMessage({
id: '1',
participantId: 'p1',
participantName: 'Claude',
content: 'Test',
timestamp: Date.now(),
isStreaming: false,
});
});
expect(result.current.messages).toHaveLength(1);
});
});frontend/Dockerfile:
FROM node:20-alpine AS base
# Dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]docker-compose.yml (Root):
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://backend:8000
depends_on:
- backend
networks:
- quorum-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
networks:
- quorum-network
networks:
quorum-network:
driver: bridgepackage.json:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\""
}
}# 1. Clone repository
git clone <repo-url>
cd quorum/frontend
# 2. Install dependencies
npm install
# 3. Set up environment
cp .env.example .env.local
# Edit .env.local with backend URL
# 4. Start development server
npm run dev
# 5. Backend should be running on localhost:8000
# Frontend runs on localhost:3000-
Multi-participant debates
- Extend to 2-4 simultaneous participants
- Color-coded messages
- Participant indicators
-
Message reactions
- Like/dislike messages
- Custom emoji reactions
-
Search functionality
- Full-text search in message history
- Filter by participant
-
Export functionality
- Export chat as Markdown
- Export as JSON
-
Settings panel
- API key management
- Theme customization
- Notification preferences
- Add comprehensive error boundaries
- Implement retry logic with exponential backoff
- Add message persistence (IndexedDB)
- Optimize bundle size analysis
- Add E2E tests with Playwright
| Decision | Choice | Rationale |
|---|---|---|
| Framework | Next.js 15 App Router | Server components, streaming support, modern React features |
| State Management | Zustand | Lightweight, TypeScript-first, minimal boilerplate |
| Server State | TanStack Query | Industry standard, built-in caching and retry logic |
| UI Components | shadcn/ui | Copy-paste components, full customization, Radix UI primitives |
| Styling | Tailwind CSS | Utility-first, rapid development, excellent Next.js integration |
| Streaming | SSE (EventSource) | One-way communication, automatic reconnection, native browser API |
| Virtualization | TanStack Virtual | Handles long message lists efficiently |
| Type Safety | TypeScript strict | Catch errors at compile time, better DX |
{
"dependencies": {
"next": "^14.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-virtual": "^3.0.0",
"zustand": "^4.4.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"tailwind-merge": "^2.0.0",
"lucide-react": "^0.294.0"
}
}{
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"typescript": "^5.0.0",
"tailwindcss": "^3.3.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.0.0",
"eslint-config-next": "^14.0.0",
"prettier": "^3.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/jest-dom": "^6.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0"
}
}- Time to Interactive (TTI): < 2s
- First Contentful Paint (FCP): < 1s
- Streaming latency: First token < 500ms
- Message render time: < 16ms (60 FPS)
- Bundle size: < 200KB (gzipped)
- Type coverage: 100%
- Test coverage: > 80%
- Accessibility: WCAG 2.2 AA compliant
- Browser support: Chrome, Firefox, Safari, Edge (latest 2 versions)
This frontend architecture provides a solid foundation for Quorum's Phase 1 chat interface. The design prioritizes:
- Performance - SSE streaming, virtual scrolling, optimized rendering
- Developer Experience - TypeScript, modular components, clear separation of concerns
- Scalability - Easy to extend for future debate features
- Maintainability - Well-documented, tested, and follows best practices
Next Steps:
- Review and approve this architecture
- Set up development environment
- Implement core components (Week 1-2)
- Integrate with backend SSE endpoints (Week 2-3)
- Add polish and testing (Week 3-4)
Document Version: 1.0 Last Updated: November 30, 2025 Author: Analyst Agent (Hive Mind Swarm)