Chat Interface
Real-time messaging with message persistence, markdown support, and file uploads
Chat Interface
The chat interface enables real-time communication between solvers and customers during support sessions. Messages are persisted to the database, broadcast via Ably for instant delivery, and support markdown formatting and file attachments.
Architecture Overview
The chat system combines three layers:
- Database persistence - Messages stored in PostgreSQL via Drizzle ORM
- Real-time broadcast - Ably pub/sub for instant message delivery
- Optimistic UI - Messages appear immediately while persisting in the background
This architecture ensures messages are never lost (database is source of truth) while providing a responsive user experience (optimistic updates with real-time sync).
Database Schema
Chat messages are stored in the session_messages table:
export const sessionMessages = pgTable("session_messages", {
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id").notNull().references(() => sessions.id),
senderId: text("sender_id"), // Clerk user ID (null for system)
senderName: text("sender_name"), // Display name
senderType: senderTypeEnum("sender_type").notNull(), // customer | solver | system
content: text("content").notNull(),
attachments: jsonb("attachments").$type<MessageAttachment[]>().default([]),
systemEventType: text("system_event_type"), // For system messages
createdAt: timestamp("created_at").notNull().defaultNow(),
});The attachments field stores file metadata as JSON, referencing files uploaded to Cloudflare R2:
export interface MessageAttachment {
url: string; // Public CDN URL
filename: string; // Original filename
contentType: string; // MIME type
size: number; // Bytes
}tRPC Procedures
The sessions router provides three procedures for chat functionality:
getMessages
Retrieves paginated message history for a session:
getMessages: publicProcedure
.input(z.object({
sessionId: z.string().uuid(),
limit: z.number().min(1).max(100).default(50),
cursor: z.string().uuid().optional(),
}))
.query(async ({ input }) => {
// Fetch messages ordered by createdAt desc
// Return { items, nextCursor } for infinite scroll
})The procedure uses cursor-based pagination, returning the oldest messages first after reversing the query results. This allows the chat to load history from newest to oldest while displaying in chronological order.
sendMessage
Persists a message and broadcasts it via Ably:
sendMessage: publicProcedure
.input(z.object({
sessionId: z.string().uuid(),
senderId: z.string(),
senderName: z.string(),
senderType: z.enum(["customer", "solver"]),
content: z.string().min(1).max(10000),
}))
.mutation(async ({ input }) => {
// 1. Insert message into database
const [message] = await db.insert(sessionMessages).values(input).returning();
// 2. Broadcast via Ably for real-time delivery
await publishToChannel(
CHANNEL_NAMES.sessionChat(sessionId),
MESSAGE_EVENTS.CHAT_MESSAGE,
chatMessage
);
return message;
})Messages are rejected if the session status is completed, preventing post-session chat.
createSystemMessage
Creates system announcements for session events:
createSystemMessage: protectedProcedure
.input(z.object({
sessionId: z.string().uuid(),
content: z.string(),
eventType: z.string(), // e.g., "session_started", "solver_joined"
}))
.mutation(async ({ input }) => {
// Insert with senderType: "system"
// Broadcast with special system message format
})useChat Hook
The useChat hook provides a complete chat interface for React components:
const {
messages, // All messages (history + real-time)
sendMessage, // Send a new message
startTyping, // Signal typing started
stopTyping, // Signal typing stopped
typingUsers, // List of users currently typing
isConnected, // Ably connection status
isLoadingHistory, // Initial history load
loadMore, // Fetch older messages
hasMore, // More history available
} = useChat({
sessionId: "session_123",
userId: user.id,
userName: user.name,
userRole: "customer",
});Message Flow
When a user sends a message:
- Optimistic update - Message appears immediately in the UI with a temporary ID
- tRPC mutation -
sendMessagepersists to database and broadcasts via Ably - ID replacement - Temporary ID replaced with real database ID on success
- Rollback on error - Optimistic message removed if mutation fails
When receiving a message from another user:
- Ably subscription -
handleMessagecallback receives the broadcast - Deduplication - Skip if message ID already exists (from history or own send)
- State update - Add to
realtimeMessagesarray - Merged display -
messagescombines history and real-time, sorted by timestamp
History Loading
The hook uses tRPC's infinite query for pagination:
const { data, fetchNextPage, hasNextPage } = trpc.sessions.getMessages
.useInfiniteQuery(
{ sessionId, limit: 50 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);History messages are converted to ChatMessage format and merged with real-time messages using useMemo to avoid unnecessary re-renders.
ChatWindow Component
The ChatWindow component provides the complete chat UI:
<ChatWindow
sessionId={session.id}
userId={user.id}
userName={user.name}
userRole="solver"
/>Features
- Markdown rendering - Bold, italic, links, and inline code via
react-markdown - System messages - Distinct styling for session events
- Typing indicators - Animated dots when other user is typing
- Presence awareness - Shows who's online in the session
- Auto-scroll - Scrolls to bottom on new messages
- Load more - Button to fetch older message history
- Relative timestamps - "Just now", "5m ago", "2:30 PM"
Message Types
The component renders three message types differently:
- Own messages - Right-aligned, primary color background
- Other messages - Left-aligned, muted background, shows sender name
- System messages - Centered, subtle styling with info icon
File Uploads with R2
Chat supports image and file attachments via Cloudflare R2 storage.
Upload Flow
- Client requests presigned URL from
/api/upload - Client uploads directly to R2 using the presigned URL
- Client includes attachment metadata when sending the message
R2 Client
export function getR2Client(): S3Client {
return new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
}Presigned URLs
The upload API generates time-limited URLs for direct browser uploads:
export async function getUploadUrl(
sessionId: string,
filename: string,
contentType: AllowedMimeType,
contentLength: number
): Promise<{ uploadUrl: string; key: string; publicUrl: string }> {
const key = generateFileKey(sessionId, filename);
const command = new PutObjectCommand({ Bucket, Key: key, ContentType, ContentLength });
const uploadUrl = await getSignedUrl(client, command, { expiresIn: 600 });
return { uploadUrl, key, publicUrl: `${R2_PUBLIC_URL}/${key}` };
}useFileUpload Hook
const { upload, isUploading, progress, error } = useFileUpload({
sessionId: session.id,
onUploadComplete: (result) => {
// Include attachment in next message
},
});
// Upload a file
const result = await upload(file);
// result.publicUrl is the CDN URL for the uploaded fileAllowed File Types
- Images: JPEG, PNG, GIF, WebP
- Documents: PDF
- Maximum size: 10MB
Environment Variables
Required configuration for chat functionality:
# Ably Real-Time (required for chat)
ABLY_API_KEY=your-ably-api-key
# Cloudflare R2 (required for file uploads)
CLOUDFLARE_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_CHAT_ATTACHMENTS_BUCKET=savvysolve-chat-attachments
R2_PUBLIC_URL=https://cdn.savvysolve.ioTesting Strategy
Key test scenarios for the chat system:
- Message persistence - Verify messages are saved to database
- Real-time delivery - Mock Ably to test broadcast on send
- Pagination - Test cursor-based history loading
- Optimistic updates - Verify rollback on mutation failure
- File uploads - Mock R2 presigned URL generation
Related Documentation
- Ably Real-Time Integration - WebSocket infrastructure
- Session Interface - Parent session workspace
- Sessions Router - Full tRPC API reference