Files
smart-support/frontend/src/pages/ReplayListPage.tsx
Yaojia Wang e0931daece feat: wire frontend pages to live APIs and standardize response contracts (P1)
- Backend: Add COUNT query and paginated response shape to conversations endpoint
  Returns { conversations: [...], total, page, per_page } instead of flat array
- Frontend: Replace mock data in DashboardPage with fetchAnalytics() API calls
- Frontend: Replace mock data in ReplayListPage with fetchConversations() API calls
- Frontend: Replace mock data in ReplayPage with fetchReplay() API calls
- Add proper loading, empty, and error states to all three pages
- Align ConversationSummary type with actual DB columns (created_at, status)
- Update unit and E2E tests for new paginated conversation response shape
- Add fetchone() to FakeCursor for COUNT query support in E2E tests
2026-04-05 23:06:00 +02:00

152 lines
7.2 KiB
TypeScript

import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { fetchConversations, ConversationSummary } from "../api";
export function ReplayListPage() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [perPage] = useState(20);
const [total, setTotal] = useState(0);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
fetchConversations(page, perPage)
.then((result) => {
setConversations(result.conversations);
setTotal(result.total);
})
.catch((err: Error) => setError(err.message))
.finally(() => setIsLoading(false));
}, [page, perPage]);
const totalPages = Math.max(1, Math.ceil(total / perPage));
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function formatCost(usd: number): string {
return `$${usd.toFixed(2)}`;
}
return (
<div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
<div>
<h2>Conversation Replay</h2>
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
</div>
</div>
{error ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}>
<p style={{ fontSize: "1.125rem", fontWeight: 600, color: "var(--brand-accent)" }}>Failed to load conversations</p>
<p style={{ marginTop: "0.5rem" }}>{error}</p>
<button onClick={() => setPage(1)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button>
</div>
) : isLoading ? (
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)", padding: "2rem" }}>
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-box" style={{ height: "60px", marginBottom: "1rem", borderRadius: "8px" }}>
<div className="skeleton-text" style={{ width: "30%", height: "14px", margin: "12px 16px" }}></div>
</div>
))}
</div>
) : conversations.length === 0 ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}>
<p style={{ fontSize: "1.125rem", fontWeight: 600 }}>No conversations yet</p>
<p style={{ marginTop: "0.5rem" }}>Start a chat session to see conversations here.</p>
</div>
) : (
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
<thead>
<tr style={{ backgroundColor: "var(--bg-surface-inner)", borderBottom: "1px solid var(--border-light)" }}>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Created</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Last Activity</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Status</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Cost</th>
</tr>
</thead>
<tbody>
{conversations.map((c, i) => (
<tr
key={c.thread_id}
onClick={() => navigate(`/replay/${c.thread_id}`)}
style={{
borderBottom: i === conversations.length - 1 ? "none" : "1px solid var(--border-light)",
cursor: "pointer",
transition: "background-color 0.2s"
}}
className="replay-row-hover"
>
<td style={{ padding: "1.25rem 1.5rem" }}>
<div style={{ fontWeight: 600, color: "var(--text-primary)", fontSize: "0.9375rem", fontFamily: "monospace" }}>{c.thread_id}</div>
</td>
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
{formatDate(c.created_at)}
</td>
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
{formatDate(c.last_activity)}
</td>
<td style={{ padding: "1.25rem 1.5rem" }}>
<span style={{
fontSize: "0.75rem",
padding: "4px 10px",
borderRadius: "6px",
fontWeight: 600,
backgroundColor: c.status === "resolved" ? "#DEF7EC" : c.status === "escalated" ? "#FDE8E8" : "var(--bg-hover)",
color: c.status === "resolved" ? "#03543F" : c.status === "escalated" ? "#9B1C1C" : "var(--text-secondary)",
}}>
{c.status ?? "active"}
</span>
</td>
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
{c.total_tokens.toLocaleString()} tokens / {formatCost(c.total_cost_usd)}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ padding: "1.25rem 1.5rem", borderTop: "1px solid var(--border-light)", display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "var(--bg-surface-inner)" }}>
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>
Showing {(page - 1) * perPage + 1}-{Math.min(page * perPage, total)} of {total} sessions
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }}
disabled={page === 1}
className="btn btn-secondary"
>
Previous
</button>
<button
onClick={(e) => { e.stopPropagation(); setPage(p => Math.min(totalPages, p + 1)) }}
disabled={page >= totalPages}
className="btn btn-secondary"
>
Next
</button>
</div>
</div>
</div>
)}
<style>{`
.replay-row-hover:hover {
background-color: var(--bg-hover) !important;
}
`}</style>
</div>
);
}