From e43feb6163ca865cfca20b0b3d7bf3486cd54f29 Mon Sep 17 00:00:00 2001 From: SamoilenkoVadym Date: Tue, 27 Jan 2026 21:51:44 +0000 Subject: [PATCH] Add detailed user analytics page with comprehensive statistics Features: - New UserDetailsView component with expandable conversations - Each conversation shows all messages with token usage and cost - User information section (email, role, status, last login) - Token usage statistics grid (8 stat cards) - Message content truncation for long messages (500 chars) - Role-based styling (user: blue, assistant: gold) Backend: - New GET /admin/users/{user_id}/details endpoint - Complex SQL queries with joins for user stats and conversations - Pydantic schemas: UserDetails, ConversationDetail, MessageDetail - Per-message and per-conversation token tracking Frontend: - React Router integration for /admin/users/:userId route - Navigation from Usage page "View" button to user details - Back button to return to admin panel - Proper error handling and loading states - Responsive CSS styling with hover effects Changes: - backend/app/api/v1/endpoints/admin.py: Added getUserDetails endpoint - frontend/src/components/UserDetailsView.tsx: New component - frontend/src/App.tsx: Added route for user details page - frontend/src/components/TokenUsageDashboard.tsx: Added navigation handler - frontend/src/services/api.ts: Added adminAPI.getUserDetails method - frontend/src/styles/admin.css: Added comprehensive styling for user details - frontend/package.json: Added react-router-dom dependency Co-Authored-By: Claude Sonnet 4.5 --- backend/app/api/v1/endpoints/admin.py | 187 ++++++++++ frontend/package-lock.json | 20 +- frontend/package.json | 8 +- frontend/src/App.tsx | 40 ++- .../src/components/TokenUsageDashboard.tsx | 6 +- frontend/src/components/UserDetailsView.tsx | 286 +++++++++++++++ frontend/src/services/api.ts | 4 + frontend/src/styles/admin.css | 326 ++++++++++++++++++ 8 files changed, 841 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/UserDetailsView.tsx diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index f8bffd9..9122cfe 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -71,6 +71,48 @@ class UserAnalytics(BaseModel): last_activity: datetime | None +class MessageDetail(BaseModel): + """Message detail for user details view""" + id: str + role: str + content: str + created_at: datetime + token_count: int + cost_usd: float + + +class ConversationDetail(BaseModel): + """Conversation detail for user details view""" + id: str + title: str + created_at: datetime + last_message_at: datetime | None + message_count: int + total_tokens: int + total_cost_usd: float + messages: List[MessageDetail] + + +class UserDetails(BaseModel): + """Detailed user information with conversations and messages""" + user_id: str + email: str + display_name: str + role: str + is_active: bool + created_at: datetime + last_login_at: datetime | None + total_conversations: int + total_messages: int + total_tokens: int + prompt_tokens: int + completion_tokens: int + cached_tokens: int + total_cost_usd: float + avg_tokens_per_message: float + conversations: List[ConversationDetail] + + # Endpoints @router.get("/users", response_model=List[UserListItem]) @@ -445,3 +487,148 @@ async def get_all_conversations( } for conv, user in conversations ] + + +@router.get("/users/{user_id}/details", response_model=UserDetails) +async def get_user_details( + user_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), + limit_conversations: int = 20 +): + """ + Get detailed information for a specific user (admin only) + + Args: + user_id: User ID to get details for + current_user: Current authenticated user + db: Database session + limit_conversations: Max number of conversations to include + + Returns: + Detailed user information with conversations and messages + + Raises: + HTTPException: If user is not admin or user not found + """ + require_permission(current_user.role, Permission.READ_ALL_CHATS) + + # Get user + user_result = await db.execute( + select(User).where(User.id == user_id) + ) + user = user_result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Get user's token usage stats + token_stats = await db.execute( + select( + func.count(TokenUsage.id).label("total_messages"), + func.sum(TokenUsage.total_tokens).label("total_tokens"), + func.sum(TokenUsage.prompt_tokens).label("prompt_tokens"), + func.sum(TokenUsage.completion_tokens).label("completion_tokens"), + func.sum(TokenUsage.cached_tokens).label("cached_tokens"), + func.sum(TokenUsage.cost_usd).label("total_cost_usd") + ).where(TokenUsage.user_id == user_id) + ) + stats_row = token_stats.first() + + total_messages = int(stats_row.total_messages or 0) + total_tokens = int(stats_row.total_tokens or 0) + prompt_tokens = int(stats_row.prompt_tokens or 0) + completion_tokens = int(stats_row.completion_tokens or 0) + cached_tokens = int(stats_row.cached_tokens or 0) + total_cost_usd = float(stats_row.total_cost_usd or 0.0) + avg_tokens_per_message = total_tokens / total_messages if total_messages > 0 else 0 + + # Get user's conversations with message counts and token stats + conv_query = ( + select( + Conversation, + func.count(Message.id).label("message_count"), + func.sum(TokenUsage.total_tokens).label("total_tokens"), + func.sum(TokenUsage.cost_usd).label("total_cost") + ) + .outerjoin(Message, Conversation.id == Message.conversation_id) + .outerjoin(TokenUsage, Conversation.id == TokenUsage.conversation_id) + .where(Conversation.user_id == user_id) + .group_by(Conversation.id) + .order_by(Conversation.last_message_at.desc().nullslast()) + .limit(limit_conversations) + ) + + conv_result = await db.execute(conv_query) + conversations_data = conv_result.all() + + # Build conversation details with messages + conversations = [] + for conv, message_count, conv_tokens, conv_cost in conversations_data: + # Get messages for this conversation + msg_query = ( + select(Message, TokenUsage) + .outerjoin(TokenUsage, + (TokenUsage.conversation_id == Message.conversation_id) & + (TokenUsage.message_id == Message.id)) + .where(Message.conversation_id == conv.id) + .order_by(Message.created_at.asc()) + ) + + msg_result = await db.execute(msg_query) + messages_data = msg_result.all() + + messages = [ + MessageDetail( + id=str(msg.id), + role=msg.role, + content=msg.content, + created_at=msg.created_at, + token_count=int(token.total_tokens) if token else 0, + cost_usd=float(token.cost_usd) if token else 0.0 + ) + for msg, token in messages_data + ] + + conversations.append( + ConversationDetail( + id=str(conv.id), + title=conv.title, + created_at=conv.created_at, + last_message_at=conv.last_message_at, + message_count=int(message_count or 0), + total_tokens=int(conv_tokens or 0), + total_cost_usd=float(conv_cost or 0.0), + messages=messages + ) + ) + + # Get total conversation count + total_conv_count = await db.scalar( + select(func.count(Conversation.id)) + .where(Conversation.user_id == user_id) + ) + + logger.info(f"Admin {current_user.id} viewed details for user {user_id}") + + return UserDetails( + user_id=str(user.id), + email=user.email, + display_name=user.display_name, + role=user.role, + is_active=user.is_active, + created_at=user.created_at, + last_login_at=user.last_login_at, + total_conversations=total_conv_count or 0, + total_messages=total_messages, + total_tokens=total_tokens, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + cached_tokens=cached_tokens, + total_cost_usd=total_cost_usd, + avg_tokens_per_message=round(avg_tokens_per_message, 1), + conversations=conversations + ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 16910fd..a653dbe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", - "react-router-dom": "^6.21.1", + "react-router-dom": "^6.30.3", "recharts": "^2.10.4" }, "devDependencies": { @@ -4154,6 +4154,7 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/q": { @@ -4181,6 +4182,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -18899,22 +18901,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ebc2871..fb2f9d1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,19 +6,19 @@ "dependencies": { "@azure/msal-browser": "^3.7.1", "@azure/msal-react": "^2.0.11", + "axios": "^1.6.5", + "prismjs": "^1.29.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.21.1", - "axios": "^1.6.5", "react-markdown": "^9.0.1", - "prismjs": "^1.29.0", + "react-router-dom": "^6.30.3", "recharts": "^2.10.4" }, "devDependencies": { "@types/node": "^20.11.0", + "@types/prismjs": "^1.26.3", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", - "@types/prismjs": "^1.26.3", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", "eslint": "^8.56.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a16bd5d..eed9b06 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ */ import React, { useEffect, useState } from 'react'; +import { BrowserRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; import { AuthProvider, useAuth } from './context/AuthContext'; import { ChatProvider, useChat } from './context/ChatContext'; import SimpleLogin from './components/SimpleLogin'; @@ -12,16 +13,26 @@ import ChatInterface from './components/ChatInterface'; import ChatList from './components/ChatList'; import TokenUsageDashboard from './components/TokenUsageDashboard'; import AdminPanel from './components/AdminPanel'; +import UserDetailsView from './components/UserDetailsView'; const AppContent: React.FC = () => { const { isAuthenticated, isLoading, user, logout, login } = useAuth(); const { loadConversations, clearState } = useChat(); - const [activeView, setActiveView] = useState<'chat' | 'usage' | 'admin'>('chat'); + const navigate = useNavigate(); + const location = useLocation(); const [sidebarOpen, setSidebarOpen] = useState(true); const [prevUserId, setPrevUserId] = useState(null); const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'; + // Determine active view from current route + const getActiveView = () => { + if (location.pathname.startsWith('/usage')) return 'usage'; + if (location.pathname.startsWith('/admin')) return 'admin'; + return 'chat'; + }; + const activeView = getActiveView(); + // Clear chat state when user logs out useEffect(() => { if (!isAuthenticated) { @@ -81,7 +92,7 @@ const AppContent: React.FC = () => {
@@ -89,13 +100,13 @@ const AppContent: React.FC = () => { <> @@ -124,9 +135,12 @@ const AppContent: React.FC = () => { )}
- {activeView === 'chat' && } - {activeView === 'usage' && } - {activeView === 'admin' && isAdmin && } + + } /> + } /> + } /> + } /> +
@@ -135,11 +149,13 @@ const AppContent: React.FC = () => { function App() { return ( - - - - - + + + + + + + ); } diff --git a/frontend/src/components/TokenUsageDashboard.tsx b/frontend/src/components/TokenUsageDashboard.tsx index 05e4660..d70451d 100644 --- a/frontend/src/components/TokenUsageDashboard.tsx +++ b/frontend/src/components/TokenUsageDashboard.tsx @@ -6,6 +6,7 @@ */ import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { tokenAPI } from '../services/api'; import { useAuth } from '../context/AuthContext'; @@ -52,6 +53,7 @@ interface UserTokenStats { const TokenUsageDashboard: React.FC = () => { const { user } = useAuth(); + const navigate = useNavigate(); const [usage, setUsage] = useState(null); const [dailyUserUsage, setDailyUserUsage] = useState([]); const [usersStats, setUsersStats] = useState([]); @@ -95,9 +97,7 @@ const TokenUsageDashboard: React.FC = () => { }, [loadUsage]); const handleViewUserDetails = (userId: string) => { - // TODO: Navigate to user details page (to be implemented) - console.log('View details for user:', userId); - alert('User details view coming soon!'); + navigate(`/admin/users/${userId}`); }; if (isLoading) { diff --git a/frontend/src/components/UserDetailsView.tsx b/frontend/src/components/UserDetailsView.tsx new file mode 100644 index 0000000..46a62b1 --- /dev/null +++ b/frontend/src/components/UserDetailsView.tsx @@ -0,0 +1,286 @@ +/** + * User Details View Component + * + * Displays comprehensive analytics for a specific user (admin only) + */ + +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { adminAPI } from '../services/api'; + +interface MessageDetail { + id: string; + role: string; + content: string; + created_at: string; + token_count: number; + cost_usd: number; +} + +interface ConversationDetail { + id: string; + title: string; + created_at: string; + last_message_at: string | null; + message_count: number; + total_tokens: number; + total_cost_usd: number; + messages: MessageDetail[]; +} + +interface UserDetails { + user_id: string; + email: string; + display_name: string; + role: string; + is_active: boolean; + created_at: string; + last_login_at: string | null; + total_conversations: number; + total_messages: number; + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; + cached_tokens: number; + total_cost_usd: number; + avg_tokens_per_message: number; + conversations: ConversationDetail[]; +} + +const UserDetailsView: React.FC = () => { + const { userId } = useParams<{ userId: string }>(); + const navigate = useNavigate(); + const [userDetails, setUserDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedConversations, setExpandedConversations] = useState>(new Set()); + + useEffect(() => { + loadUserDetails(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); + + const loadUserDetails = async () => { + if (!userId) { + setError('User ID is required'); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setError(null); + const response = await adminAPI.getUserDetails(userId); + setUserDetails(response.data); + } catch (err: any) { + console.error('Failed to load user details:', err); + setError(err.response?.data?.detail || 'Failed to load user details'); + } finally { + setIsLoading(false); + } + }; + + const toggleConversation = (conversationId: string) => { + setExpandedConversations((prev) => { + const newSet = new Set(prev); + if (newSet.has(conversationId)) { + newSet.delete(conversationId); + } else { + newSet.add(conversationId); + } + return newSet; + }); + }; + + const handleBack = () => { + navigate('/admin'); + }; + + if (isLoading) { + return ( +
+
Loading user details...
+
+ ); + } + + if (error || !userDetails) { + return ( +
+
{error || 'No data available'}
+ +
+ ); + } + + return ( +
+
+ +

User Details: {userDetails.display_name}

+
+ + {/* User Information Section */} +
+

User Information

+
+
+ Email: + {userDetails.email} +
+
+ Role: + {userDetails.role} +
+
+ Status: + + {userDetails.is_active ? 'Active' : 'Inactive'} + +
+
+ Created: + + {new Date(userDetails.created_at).toLocaleDateString()} + +
+
+ Last Login: + + {userDetails.last_login_at + ? new Date(userDetails.last_login_at).toLocaleString() + : 'Never'} + +
+
+
+ + {/* Token Statistics Section */} +
+

Token Usage Statistics

+
+
+
Total Conversations
+
{userDetails.total_conversations}
+
+
+
Total Messages
+
{userDetails.total_messages}
+
+
+
Total Tokens
+
{userDetails.total_tokens.toLocaleString()}
+
+
+
Prompt Tokens
+
{userDetails.prompt_tokens.toLocaleString()}
+
+
+
Completion Tokens
+
{userDetails.completion_tokens.toLocaleString()}
+
+
+
Cached Tokens
+
{userDetails.cached_tokens.toLocaleString()}
+
+
+
Total Cost
+
${userDetails.total_cost_usd.toFixed(4)}
+
+
+
Avg Tokens/Message
+
{userDetails.avg_tokens_per_message.toFixed(1)}
+
+
+
+ + {/* Conversations Section */} +
+

Conversations ({userDetails.conversations.length})

+ {userDetails.conversations.length === 0 ? ( +
No conversations found
+ ) : ( +
+ {userDetails.conversations.map((conversation) => ( +
+
toggleConversation(conversation.id)} + > +
+ + {expandedConversations.has(conversation.id) ? '▼' : '▶'} + + {conversation.title} +
+
+ {conversation.message_count} messages + {conversation.total_tokens.toLocaleString()} tokens + ${conversation.total_cost_usd.toFixed(4)} +
+
+ + {expandedConversations.has(conversation.id) && ( +
+
+
+ Created:{' '} + {new Date(conversation.created_at).toLocaleString()} +
+
+ Last Activity:{' '} + {conversation.last_message_at + ? new Date(conversation.last_message_at).toLocaleString() + : 'N/A'} +
+
+ Total Tokens:{' '} + {conversation.total_tokens.toLocaleString()} +
+
+ Total Cost:{' '} + ${conversation.total_cost_usd.toFixed(4)} +
+
+ +
+

Messages

+
+ {conversation.messages.map((message) => ( +
+
+ + {message.role === 'user' ? '👤 User' : '🤖 Assistant'} + + + {new Date(message.created_at).toLocaleString()} + +
+
+ {message.content.length > 500 + ? message.content.substring(0, 500) + '...' + : message.content} +
+
+ Tokens: {message.token_count} + Cost: ${message.cost_usd.toFixed(4)} +
+
+ ))} +
+
+
+ )} +
+ ))} +
+ )} +
+
+ ); +}; + +export default UserDetailsView; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1d91893..e6cad1a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -156,4 +156,8 @@ export const adminAPI = { getAllConversations: (skip: number = 0, limit: number = 50) => apiClient.get('/admin/conversations/all', { params: { skip, limit } }), + + // User details + getUserDetails: (userId: string) => + apiClient.get(`/admin/users/${userId}/details`), }; diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index bb5c6f8..7849e76 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -318,3 +318,329 @@ margin: 0; font-size: 0.95rem; } + +/* User Details View */ +.user-details-view { + width: 100%; + height: 100%; + overflow: auto; + padding: 2rem; + background: var(--bg-secondary); +} + +.user-details-header { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.user-details-header h2 { + margin: 0; + font-size: 1.75rem; + color: var(--text-primary); +} + +.btn-back { + padding: 0.625rem 1.25rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.95rem; + cursor: pointer; + transition: all var(--transition-normal); + font-weight: 500; +} + +.btn-back:hover { + background: var(--bg-hover); + border-color: var(--primary-gold); + color: var(--primary-gold); +} + +.user-details-loading, +.user-details-error { + text-align: center; + padding: 3rem; + color: var(--text-secondary); + font-size: 1.1rem; +} + +.user-details-error { + color: var(--error-color); +} + +/* User Information Section */ +.user-info-section { + background: var(--white); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: var(--shadow-sm); +} + +.user-info-section h3 { + margin: 0 0 1.25rem 0; + font-size: 1.25rem; + color: var(--text-primary); + border-bottom: 2px solid var(--primary-gold); + padding-bottom: 0.5rem; +} + +.user-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; +} + +.user-info-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.user-info-label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +.user-info-value { + font-size: 1rem; + color: var(--text-primary); + font-weight: 500; +} + +/* User Stats Section */ +.user-stats-section { + background: var(--white); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: var(--shadow-sm); +} + +.user-stats-section h3 { + margin: 0 0 1.25rem 0; + font-size: 1.25rem; + color: var(--text-primary); + border-bottom: 2px solid var(--primary-gold); + padding-bottom: 0.5rem; +} + +.user-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1.5rem; +} + +.user-stat-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 1.25rem; + text-align: center; + transition: all var(--transition-normal); +} + +.user-stat-card:hover { + border-color: var(--primary-gold); + box-shadow: var(--shadow-sm); + transform: translateY(-2px); +} + +.user-stat-label { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-weight: 500; +} + +.user-stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--primary-gold); +} + +/* Conversations Section */ +.user-conversations-section { + background: var(--white); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); +} + +.user-conversations-section h3 { + margin: 0 0 1.25rem 0; + font-size: 1.25rem; + color: var(--text-primary); + border-bottom: 2px solid var(--primary-gold); + padding-bottom: 0.5rem; +} + +.conversations-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.conversation-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + overflow: hidden; + transition: all var(--transition-normal); +} + +.conversation-card:hover { + border-color: var(--primary-gold); + box-shadow: var(--shadow-sm); +} + +.conversation-header { + padding: 1rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background var(--transition-normal); +} + +.conversation-header:hover { + background: var(--bg-hover); +} + +.conversation-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-weight: 600; + color: var(--text-primary); + font-size: 1rem; +} + +.expand-icon { + color: var(--primary-gold); + font-size: 0.875rem; +} + +.archived-badge { + padding: 0.25rem 0.5rem; + background: var(--error-bg); + color: var(--error-color); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; +} + +.conversation-stats { + display: flex; + gap: 1.5rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.conversation-details { + padding: 1rem; + border-top: 1px solid var(--border-color); + background: var(--white); +} + +.conversation-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + margin-bottom: 1rem; + font-size: 0.875rem; + color: var(--text-primary); +} + +.conversation-meta strong { + color: var(--text-secondary); + font-weight: 600; +} + +/* Messages Section */ +.messages-section h4 { + margin: 0 0 1rem 0; + font-size: 1.1rem; + color: var(--text-primary); +} + +.messages-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.message-item { + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--white); +} + +.message-item.user { + border-left: 3px solid var(--primary-blue); +} + +.message-item.assistant { + border-left: 3px solid var(--primary-gold); +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.message-role { + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; +} + +.message-date { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.message-content { + padding: 0.75rem; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.95rem; + line-height: 1.5; + margin-bottom: 0.75rem; + white-space: pre-wrap; + word-wrap: break-word; +} + +.message-tokens { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: var(--text-secondary); + padding-top: 0.5rem; + border-top: 1px solid var(--border-color); +} + +.message-tokens span { + font-weight: 500; +} + +.empty-state { + text-align: center; + padding: 3rem; + color: var(--text-secondary); + font-size: 1rem; +}