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
This commit is contained in:
@@ -14,6 +14,10 @@ if TYPE_CHECKING:
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["replay"])
|
||||
|
||||
_COUNT_CONVERSATIONS_SQL = """
|
||||
SELECT COUNT(*) FROM conversations
|
||||
"""
|
||||
|
||||
_LIST_CONVERSATIONS_SQL = """
|
||||
SELECT thread_id, created_at, last_activity, status, total_tokens, total_cost_usd
|
||||
FROM conversations
|
||||
@@ -48,13 +52,22 @@ async def list_conversations(
|
||||
pool = await get_pool(request)
|
||||
offset = (page - 1) * per_page
|
||||
async with pool.connection() as conn:
|
||||
count_cursor = await conn.execute(_COUNT_CONVERSATIONS_SQL)
|
||||
count_row = await count_cursor.fetchone()
|
||||
total = count_row[0] if count_row else 0
|
||||
|
||||
cursor = await conn.execute(
|
||||
_LIST_CONVERSATIONS_SQL,
|
||||
{"limit": per_page, "offset": offset},
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return _envelope([dict(row) for row in rows])
|
||||
return _envelope({
|
||||
"conversations": [dict(row) for row in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/replay/{thread_id}")
|
||||
|
||||
@@ -107,6 +107,9 @@ class FakeCursor:
|
||||
async def fetchall(self) -> list[dict]:
|
||||
return self._rows
|
||||
|
||||
async def fetchone(self) -> tuple | dict | None:
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
"""Fake async connection that returns a FakeCursor."""
|
||||
|
||||
@@ -44,6 +44,8 @@ class ReplayPool(FakePool):
|
||||
async def execute(self, query: str, params=None):
|
||||
from tests.e2e.conftest import FakeCursor
|
||||
|
||||
if "COUNT" in query and "conversations" in query:
|
||||
return FakeCursor([(len(self._convos),)])
|
||||
if "conversations" in query and "SELECT" in query:
|
||||
return FakeCursor(self._convos)
|
||||
if "checkpoints" in query:
|
||||
@@ -94,9 +96,11 @@ class TestFlow6ReplayConversation:
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert len(body["data"]) == 2
|
||||
assert body["data"][0]["thread_id"] == "conv-001"
|
||||
assert body["data"][1]["thread_id"] == "conv-002"
|
||||
data = body["data"]
|
||||
assert len(data["conversations"]) == 2
|
||||
assert data["conversations"][0]["thread_id"] == "conv-001"
|
||||
assert data["conversations"][1]["thread_id"] == "conv-002"
|
||||
assert data["total"] == 2
|
||||
|
||||
def test_list_conversations_pagination(self) -> None:
|
||||
conversations = [
|
||||
@@ -206,7 +210,8 @@ class TestFullUserJourney:
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert any(
|
||||
c["thread_id"] == "e2e-journey-1" for c in body["data"]
|
||||
c["thread_id"] == "e2e-journey-1"
|
||||
for c in body["data"]["conversations"]
|
||||
)
|
||||
|
||||
# Step 3: Health check still works
|
||||
|
||||
@@ -19,13 +19,35 @@ def _build_app() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock:
|
||||
"""Build a mock pool that returns the given rows from fetchall."""
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||
def _make_mock_pool(
|
||||
fetchall_result: list[dict],
|
||||
*,
|
||||
count: int | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a mock pool that returns the given rows from fetchall.
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
When *count* is provided, the first execute() call returns a cursor
|
||||
whose fetchone() yields ``(count,)`` (for the COUNT query) and the
|
||||
second call returns the rows via fetchall(). When *count* is None
|
||||
(the default), a single cursor backed by *fetchall_result* is used
|
||||
for all calls.
|
||||
"""
|
||||
if count is not None:
|
||||
count_cursor = AsyncMock()
|
||||
count_cursor.fetchone = AsyncMock(return_value=(count,))
|
||||
|
||||
rows_cursor = AsyncMock()
|
||||
rows_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(side_effect=[count_cursor, rows_cursor])
|
||||
else:
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||
mock_cursor.fetchone = AsyncMock(return_value=None)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
@@ -39,14 +61,17 @@ def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock:
|
||||
class TestListConversations:
|
||||
def test_returns_200_with_empty_list(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert isinstance(body["data"], list)
|
||||
data = body["data"]
|
||||
assert isinstance(data["conversations"], list)
|
||||
assert data["total"] == 0
|
||||
assert data["page"] == 1
|
||||
assert body["error"] is None
|
||||
|
||||
def test_returns_conversations_list(self) -> None:
|
||||
@@ -61,18 +86,20 @@ class TestListConversations:
|
||||
"total_cost_usd": 0.01,
|
||||
}
|
||||
]
|
||||
app.state.pool = _make_mock_pool(mock_rows)
|
||||
app.state.pool = _make_mock_pool(mock_rows, count=1)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
body = resp.json()
|
||||
assert resp.status_code == 200
|
||||
assert len(body["data"]) == 1
|
||||
assert body["data"][0]["thread_id"] == "t1"
|
||||
data = body["data"]
|
||||
assert len(data["conversations"]) == 1
|
||||
assert data["conversations"][0]["thread_id"] == "t1"
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_pagination_defaults(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
@@ -80,7 +107,7 @@ class TestListConversations:
|
||||
|
||||
def test_pagination_custom_params(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations?page=2&per_page=10")
|
||||
@@ -88,7 +115,7 @@ class TestListConversations:
|
||||
|
||||
def test_per_page_max_capped_at_100(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations?per_page=200")
|
||||
|
||||
@@ -10,13 +10,11 @@ export interface ApiResponse<T> {
|
||||
|
||||
export interface ConversationSummary {
|
||||
thread_id: string;
|
||||
started_at: string;
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
turn_count: number;
|
||||
agents_used: string[];
|
||||
status: string | null;
|
||||
total_tokens: number;
|
||||
total_cost_usd: number;
|
||||
resolution_type: string | null;
|
||||
}
|
||||
|
||||
export interface ConversationsPage {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchAnalytics, AnalyticsData } from "../api";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ value: "7d", label: "7 days" },
|
||||
@@ -6,36 +7,19 @@ const RANGE_OPTIONS = [
|
||||
{ value: "30d", label: "30 days" },
|
||||
];
|
||||
|
||||
// Mock Data
|
||||
const MOCK_DATA = {
|
||||
total_conversations: 4208,
|
||||
resolution_rate: 0.724,
|
||||
escalation_rate: 0.276,
|
||||
avg_turns_per_conversation: 3.4,
|
||||
total_tokens: 1450200,
|
||||
total_cost_usd: 12.45,
|
||||
agent_usage: [
|
||||
{ agent_name: "Order Specialist", message_count: 8540, total_tokens: 854000, total_cost_usd: 7.20 },
|
||||
{ agent_name: "Billing Assistant", message_count: 3120, total_tokens: 412000, total_cost_usd: 3.50 },
|
||||
{ agent_name: "Router & Orchestrator", message_count: 4208, total_tokens: 184200, total_cost_usd: 1.75 },
|
||||
],
|
||||
interrupt_stats: {
|
||||
total: 412,
|
||||
approved: 380,
|
||||
rejected: 28,
|
||||
expired: 4,
|
||||
}
|
||||
};
|
||||
|
||||
export function DashboardPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const data = MOCK_DATA;
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 1200);
|
||||
return () => clearTimeout(timer);
|
||||
setError(null);
|
||||
fetchAnalytics(range)
|
||||
.then((result) => setData(result))
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [range]);
|
||||
|
||||
function pct(value: number): string {
|
||||
@@ -80,7 +64,7 @@ export function DashboardPage() {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="skeleton-box" style={{ height: "120px", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", background: "var(--bg-surface)" }}>
|
||||
<div className="skeleton-text" style={{ width: "60%", height: "12px", marginBottom: "1.5rem" }}></div>
|
||||
<div className="skeleton-text" style={{ width: "40%", height: "30px", marginBottom: "1rem" }}></div>
|
||||
@@ -93,38 +77,52 @@ export function DashboardPage() {
|
||||
<div className="skeleton-box" style={{ height: "300px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></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 analytics</p>
|
||||
<p style={{ marginTop: "0.5rem" }}>{error}</p>
|
||||
<button onClick={() => setRange(range)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}>
|
||||
<p style={{ fontSize: "1.125rem", fontWeight: 600 }}>No analytics data available</p>
|
||||
<p style={{ marginTop: "0.5rem" }}>Start some conversations to see metrics here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
|
||||
<MetricBox label="Tickets Processed" value={data.total_conversations.toLocaleString()} trend="+12% vs last month" />
|
||||
<MetricBox label="Auto-Resolution Rate" value={pct(data.resolution_rate)} trend="Target: 70%" positive />
|
||||
<MetricBox label="Human Escalations" value={pct(data.escalation_rate)} trend="Avg 28%" />
|
||||
<MetricBox label="Human-in-the-Loop Prompts" value={data.interrupt_stats.total.toLocaleString()} trend="High Risk Actions Intercepted" />
|
||||
<MetricBox label="LLM Intelligence Cost" value={formatCost(data.total_cost_usd)} trend={`${(data.total_tokens / 1000).toLocaleString()}k Tokens`} />
|
||||
<MetricBox label="Tickets Processed" value={data.total_conversations.toLocaleString()} trend={`Range: ${data.range}`} />
|
||||
<MetricBox label="Auto-Resolution Rate" value={pct(data.resolution_rate)} trend="Target: 70%" positive={data.resolution_rate >= 0.7} />
|
||||
<MetricBox label="Human Escalations" value={pct(data.escalation_rate)} trend="Lower is better" />
|
||||
<MetricBox label="Avg Cost / Conversation" value={formatCost(data.avg_cost_per_conversation_usd)} trend={`${data.avg_turns_per_conversation.toFixed(1)} avg turns`} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
|
||||
{/* Agent Workload Table */}
|
||||
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}>
|
||||
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: "0 0 1rem 0" }}>Agent Workload Distribution</h3>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "2px solid var(--border-light)" }}>
|
||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Agent Name</th>
|
||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Actions Handled</th>
|
||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Cost Footprint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.agent_usage.map((a) => (
|
||||
<tr key={a.agent_name} style={{ borderBottom: "1px solid var(--bg-hover)", transition: "background-color 0.2s" }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
|
||||
<td style={{ padding: "1rem 0 1rem 1rem", fontWeight: 600, fontSize: "0.9375rem" }}>{a.agent_name}</td>
|
||||
<td style={{ padding: "1rem 0", fontSize: "0.9375rem" }}>{a.message_count.toLocaleString()}</td>
|
||||
<td style={{ padding: "1rem 1rem 1rem 0", fontSize: "0.9375rem" }}>{formatCost(a.total_cost_usd)}</td>
|
||||
{data.agent_usage.length === 0 ? (
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: "0.875rem" }}>No agent activity recorded yet.</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "2px solid var(--border-light)" }}>
|
||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Agent Name</th>
|
||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Message Count</th>
|
||||
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Share</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.agent_usage.map((a) => (
|
||||
<tr key={a.agent} style={{ borderBottom: "1px solid var(--bg-hover)", transition: "background-color 0.2s" }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
|
||||
<td style={{ padding: "1rem 0 1rem 1rem", fontWeight: 600, fontSize: "0.9375rem" }}>{a.agent}</td>
|
||||
<td style={{ padding: "1rem 0", fontSize: "0.9375rem" }}>{a.count.toLocaleString()}</td>
|
||||
<td style={{ padding: "1rem 1rem 1rem 0", fontSize: "0.9375rem" }}>{pct(a.percentage)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Human in the loop card */}
|
||||
@@ -136,24 +134,28 @@ export function DashboardPage() {
|
||||
<p style={{ fontSize: "0.875rem", color: "var(--text-secondary)", marginBottom: "1.5rem", lineHeight: 1.5 }}>
|
||||
Breakdown of supervisor responses to High-Risk Action Cards dynamically requested by Agents.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Approved</span>
|
||||
<span style={{ color: "#059669", fontWeight: 700 }}>{data.interrupt_stats.approved}</span>
|
||||
</div>
|
||||
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
||||
<div style={{ width: `${(data.interrupt_stats.approved / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#059669" }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "0.5rem" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Rejected (Escalated)</span>
|
||||
<span style={{ color: "#DC2626", fontWeight: 700 }}>{data.interrupt_stats.rejected}</span>
|
||||
{data.interrupt_stats.total === 0 ? (
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: "0.875rem" }}>No interrupt events recorded yet.</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Approved</span>
|
||||
<span style={{ color: "#059669", fontWeight: 700 }}>{data.interrupt_stats.approved}</span>
|
||||
</div>
|
||||
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
||||
<div style={{ width: `${(data.interrupt_stats.approved / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#059669" }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "0.5rem" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Rejected (Escalated)</span>
|
||||
<span style={{ color: "#DC2626", fontWeight: 700 }}>{data.interrupt_stats.rejected}</span>
|
||||
</div>
|
||||
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
||||
<div style={{ width: `${(data.interrupt_stats.rejected / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#DC2626" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
|
||||
<div style={{ width: `${(data.interrupt_stats.rejected / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#DC2626" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -165,10 +167,10 @@ export function DashboardPage() {
|
||||
|
||||
function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: "var(--bg-surface)",
|
||||
padding: "1.5rem",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
<div style={{
|
||||
backgroundColor: "var(--bg-surface)",
|
||||
padding: "1.5rem",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--border-light)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_CONVERSATIONS = [
|
||||
{ thread_id: "th_9281ja8s9", user: "Maria G.", intent: "Cancel Order #8921", date: "2 mins ago", turns: 4, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.02" },
|
||||
{ thread_id: "th_1092jf8u1", user: "David C.", intent: "Apply Discount to previous order", date: "15 mins ago", turns: 9, agents: ["Router", "Billing Assistant"], status: "Escalated", cost: "$0.08", hitl: true },
|
||||
{ thread_id: "th_0099ab7x2", user: "Sarah L.", intent: "Where is my package?", date: "1 hour ago", turns: 2, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.01" },
|
||||
{ thread_id: "th_5518kc3p0", user: "John M.", intent: "Change shipping address", date: "4 hours ago", turns: 6, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.04" },
|
||||
{ thread_id: "th_1102po9m4", user: "Elena P.", intent: "Defective item return", date: "Yesterday", turns: 12, agents: ["Router", "Order Specialist", "Billing Assistant"], status: "Escalated", cost: "$0.15", hitl: true },
|
||||
];
|
||||
import { fetchConversations, ConversationSummary } from "../api";
|
||||
|
||||
export function ReplayListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const totalPages = 24;
|
||||
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">
|
||||
@@ -22,104 +44,103 @@ export function ReplayListPage() {
|
||||
<h2>Conversation Replay</h2>
|
||||
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Order ID, Thread ID..."
|
||||
style={{
|
||||
padding: "0.625rem 1rem",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--border-light)",
|
||||
backgroundColor: "var(--bg-surface)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "0.875rem",
|
||||
width: "280px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</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 }}>Detected Intent</th>
|
||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Agents Invoked</th>
|
||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Outcome</th>
|
||||
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Performance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_CONVERSATIONS.map((c, i) => (
|
||||
<tr
|
||||
key={c.thread_id}
|
||||
onClick={() => navigate(`/replay/${c.thread_id}`)}
|
||||
style={{
|
||||
borderBottom: i === MOCK_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" }}>{c.user}</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", fontFamily: "monospace", marginTop: "4px" }}>{c.thread_id}</div>
|
||||
</td>
|
||||
<td style={{ padding: "1.25rem 1.5rem" }}>
|
||||
<div style={{ fontWeight: 500, color: "var(--text-primary)", fontSize: "0.9375rem" }}>{c.intent}</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginTop: "4px" }}>{c.date}</div>
|
||||
</td>
|
||||
<td style={{ padding: "1.25rem 1.5rem" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
||||
{c.agents.map(a => (
|
||||
<span key={a} style={{ fontSize: "0.65rem", padding: "2px 8px", backgroundColor: "var(--bg-app)", border: "1px solid var(--border-light)", borderRadius: "99px", color: "var(--text-secondary)", fontWeight: 600 }}>
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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" : "#FDE8E8",
|
||||
color: c.status === "Resolved" ? "#03543F" : "#9B1C1C",
|
||||
}}>
|
||||
{c.status}
|
||||
</span>
|
||||
{c.hitl && <span style={{ marginLeft: "8px", fontSize: "1.25rem" }} title="Human in the loop invoked">🔒</span>}
|
||||
</td>
|
||||
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
|
||||
{c.turns} turns • {c.cost}
|
||||
</td>
|
||||
{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>
|
||||
))}
|
||||
</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 1-5 of 120 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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.replay-row-hover:hover {
|
||||
background-color: var(--bg-hover) !important;
|
||||
|
||||
@@ -1,67 +1,92 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ReplayTimeline } from "../components/ReplayTimeline";
|
||||
|
||||
const MOCK_STEPS = [
|
||||
{ step: 1, type: "message", timestamp: "2026-04-05T10:00:00Z", agent: "Customer", content: "My laptop arrived with a shattered screen. I need a replacement immediately! Order #8921." },
|
||||
{ step: 2, type: "token", timestamp: "2026-04-05T10:00:02Z", agent: "Router", content: "Intent detected: 'return_request'. Routing to Order Specialist." },
|
||||
{ step: 3, type: "tool_call", timestamp: "2026-04-05T10:00:03Z", agent: "Order Specialist", tool: "get_order_details", params: { order_id: "8921" } },
|
||||
{ step: 4, type: "tool_result", timestamp: "2026-04-05T10:00:04Z", tool: "get_order_details", result: { status: "Delivered", items: ["MacBook Pro 16", "USB-C Hub"], total_value: 2499.00 } },
|
||||
{ step: 5, type: "tool_call", timestamp: "2026-04-05T10:00:06Z", agent: "Order Specialist", tool: "initiate_return", params: { order_id: "8921", reason: "Damaged in transit", replacement: true } },
|
||||
{ step: 6, type: "interrupt", timestamp: "2026-04-05T10:00:06Z", agent: "System", content: "SECURITY POLICY TRIGGERED: High-Value Return (>$1000). Human approval required before initiating RMS workflow." },
|
||||
{ step: 7, type: "interrupt_response", timestamp: "2026-04-05T10:15:22Z", agent: "Alex Thompson (Supervisor)", content: "REJECTED. Standard policy for shattered screens requires photo evidence before dispatching replacement unit." },
|
||||
{ step: 8, type: "message", timestamp: "2026-04-05T10:15:25Z", agent: "Order Specialist", content: "I'm so sorry to hear your laptop screen was shattered! Because this is a high-value item, our policy requires a photo of the damage before we can dispatch your replacement unit. Could you please take a quick picture and upload it here?" }
|
||||
];
|
||||
import { fetchReplay, ReplayStep } from "../api";
|
||||
|
||||
export function ReplayPage() {
|
||||
const { threadId } = useParams<{ threadId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [steps, setSteps] = useState<ReplayStep[]>([]);
|
||||
const [totalSteps, setTotalSteps] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchReplay(threadId, 1, 100)
|
||||
.then((result) => {
|
||||
setSteps(result.steps);
|
||||
setTotalSteps(result.total_steps);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [threadId]);
|
||||
|
||||
if (!threadId) return null;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
|
||||
<div>
|
||||
<button
|
||||
<button
|
||||
onClick={() => navigate("/replay")}
|
||||
style={{ background: "none", border: "none", color: "var(--text-secondary)", fontSize: "0.875rem", cursor: "pointer", padding: "0 0 0.5rem 0", display: "flex", alignItems: "center", gap: "0.25rem" }}
|
||||
>
|
||||
← Back to All Replays
|
||||
← Back to All Replays
|
||||
</button>
|
||||
<h2>Audit Trail: <span style={{ fontFamily: "monospace", color: "var(--brand-primary)" }}>{threadId}</span></h2>
|
||||
<p>Detailed temporal log of agent reflections, MCP tool calls, and human overrides.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
|
||||
{/* Sidebar Summary Info */}
|
||||
<div style={{ backgroundColor: "var(--bg-surface)", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", alignSelf: "start" }}>
|
||||
<h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Customer</div>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9375rem" }}>Maria G.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Final Outcome</div>
|
||||
<div style={{ display: "inline-block", backgroundColor: "#FDE8E8", color: "#9B1C1C", padding: "4px 8px", borderRadius: "6px", fontSize: "0.75rem", fontWeight: 700, marginTop: "4px" }}>ESCALATED 🔒</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Time Elapsed</div>
|
||||
<div style={{ fontSize: "0.9375rem" }}>15m 25s</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Total Tokens</div>
|
||||
<div style={{ fontSize: "0.9375rem" }}>3,402 ($0.15)</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 replay</p>
|
||||
<p style={{ marginTop: "0.5rem" }}>{error}</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
|
||||
<div className="skeleton-box" style={{ height: "250px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
|
||||
<div className="skeleton-box" style={{ height: "400px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
|
||||
</div>
|
||||
) : steps.length === 0 ? (
|
||||
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}>
|
||||
<p style={{ fontSize: "1.125rem", fontWeight: 600 }}>No replay steps found</p>
|
||||
<p style={{ marginTop: "0.5rem" }}>This conversation has no recorded checkpoints.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
|
||||
{/* Sidebar Summary Info */}
|
||||
<div style={{ backgroundColor: "var(--bg-surface)", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", alignSelf: "start" }}>
|
||||
<h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread ID</div>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.8125rem", fontFamily: "monospace", wordBreak: "break-all" }}>{threadId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Total Steps</div>
|
||||
<div style={{ fontSize: "0.9375rem" }}>{totalSteps}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Time Range</div>
|
||||
<div style={{ fontSize: "0.8125rem" }}>
|
||||
{steps[0]?.timestamp ? new Date(steps[0].timestamp).toLocaleString() : "N/A"}
|
||||
{" - "}
|
||||
{steps[steps.length - 1]?.timestamp ? new Date(steps[steps.length - 1].timestamp).toLocaleString() : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div style={{ backgroundColor: "var(--bg-surface)", padding: "2rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)" }}>
|
||||
<ReplayTimeline steps={MOCK_STEPS as any} />
|
||||
{/* Timeline */}
|
||||
<div style={{ backgroundColor: "var(--bg-surface)", padding: "2rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)" }}>
|
||||
<ReplayTimeline steps={steps as any} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user