refactor: formalize safety rules, extract shared styles, reconcile docs (P2)

- Add backend/app/safety.py with explicit confirmation policy, multi-intent
  semantics, and MCP error taxonomy with retry classification
- Add 26 unit tests for safety module (confirmation rules, error taxonomy)
- Extract repeated inline styles into shared CSS classes in index.css
  (section-card, stat-label, status-badge, data-table, empty/error-state,
  pagination-bar)
- Refactor DashboardPage, ReplayListPage, ReplayPage to use shared classes
- Update README: add missing API endpoints, document safety/confirmation rules
- Use proper HTML entities for arrow/dash characters to fix encoding glitches
This commit is contained in:
Yaojia Wang
2026-04-05 23:10:50 +02:00
parent e0931daece
commit 036e12349d
7 changed files with 448 additions and 120 deletions

View File

@@ -128,11 +128,24 @@ agents:
|--------|------|-------------| |--------|------|-------------|
| WS | `/ws` | Main WebSocket chat endpoint | | WS | `/ws` | Main WebSocket chat endpoint |
| GET | `/api/health` | Health check | | GET | `/api/health` | Health check |
| GET | `/api/conversations` | List conversations | | GET | `/api/conversations` | List conversations (paginated) |
| GET | `/api/replay/{thread_id}` | Replay conversation | | GET | `/api/replay/{thread_id}` | Replay conversation steps (paginated) |
| GET | `/api/analytics` | Analytics summary | | GET | `/api/analytics` | Analytics summary (`?range=7d`) |
| POST | `/api/openapi/import` | Import OpenAPI spec | | POST | `/api/openapi/import` | Start OpenAPI import job |
| GET | `/api/openapi/jobs/{id}` | Check import job status | | GET | `/api/openapi/jobs/{id}` | Check import job status |
| GET | `/api/openapi/jobs/{id}/classifications` | Get endpoint classifications |
| PUT | `/api/openapi/jobs/{id}/classifications/{idx}` | Update a classification |
| POST | `/api/openapi/jobs/{id}/approve` | Approve and generate tools |
## Safety and Confirmation Rules
Destructive-action confirmation is explicit and auditable (see `backend/app/safety.py`):
- **Read actions** execute immediately -- no confirmation required.
- **Write actions** require human-in-the-loop approval via an interrupt gate.
- **OpenAPI-imported endpoints** use the `needs_interrupt` classification flag.
- **Multi-intent handling** is sequential: if a write action is blocked by an interrupt, subsequent actions are paused until the interrupt is resolved or rejected.
- **MCP errors** are classified into `transient` (retryable, up to 3 attempts), `validation` (not retryable), `auth` (not retryable, escalate), and `unknown` (not retryable, log and escalate).
## Security ## Security

131
backend/app/safety.py Normal file
View File

@@ -0,0 +1,131 @@
"""Safety policy for destructive-action confirmation rules.
This module makes the confirmation rules explicit and auditable. Every tool
call passes through ``requires_confirmation`` before execution to decide
whether human-in-the-loop approval is needed.
Policy summary
--------------
- ``read`` actions: execute immediately, no confirmation required.
- ``write`` actions: require human approval via interrupt gate.
- OpenAPI-imported endpoints: use ``needs_interrupt`` from classification.
- If both the agent permission AND the endpoint classification agree
the action is read-only, it executes without confirmation.
Multi-intent semantics
----------------------
When a user message contains multiple intents (e.g. "cancel my order and
apply a refund"), the supervisor routes them sequentially. Each action is
evaluated independently:
- If a write action is blocked by an interrupt, subsequent actions in the
same message are paused until the interrupt is resolved.
- Read actions that follow a blocked write are also paused (sequential,
not best-effort) to preserve causal ordering.
- If an interrupt is rejected, the remaining actions are skipped and the
agent informs the user.
MCP error taxonomy
------------------
Tool execution errors are classified into categories for retry decisions:
- ``transient``: network timeouts, rate limits, 5xx -- retryable up to 3 times.
- ``validation``: bad parameters, 4xx -- not retryable, report to user.
- ``auth``: 401/403 -- not retryable, escalate.
- ``unknown``: unclassified -- not retryable, log and escalate.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
@dataclass(frozen=True)
class ConfirmationPolicy:
"""Result of evaluating whether an action needs confirmation."""
requires_confirmation: bool
reason: str
def requires_confirmation(
*,
agent_permission: Literal["read", "write"],
needs_interrupt: bool | None = None,
) -> ConfirmationPolicy:
"""Determine whether an action requires human confirmation.
Parameters
----------
agent_permission:
The permission level of the agent executing the action.
needs_interrupt:
Override from OpenAPI classification. When ``None``, the decision
is based solely on ``agent_permission``.
"""
if needs_interrupt is not None:
if needs_interrupt:
return ConfirmationPolicy(
requires_confirmation=True,
reason="Endpoint classified as requiring human approval",
)
return ConfirmationPolicy(
requires_confirmation=False,
reason="Endpoint classified as safe (no interrupt needed)",
)
if agent_permission == "write":
return ConfirmationPolicy(
requires_confirmation=True,
reason="Write-permission agent actions require confirmation",
)
return ConfirmationPolicy(
requires_confirmation=False,
reason="Read-only agent actions execute immediately",
)
# --- MCP Error Taxonomy ---
MCP_ERROR_CATEGORY = Literal["transient", "validation", "auth", "unknown"]
_TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
_AUTH_STATUS_CODES = frozenset({401, 403})
_MAX_RETRIES = 3
def classify_mcp_error(
*,
status_code: int | None = None,
error_message: str = "",
) -> MCP_ERROR_CATEGORY:
"""Classify an MCP tool error for retry decisions."""
if status_code is not None:
if status_code in _TRANSIENT_STATUS_CODES:
return "transient"
if status_code in _AUTH_STATUS_CODES:
return "auth"
if 400 <= status_code < 500:
return "validation"
lower_msg = error_message.lower()
if any(kw in lower_msg for kw in ("timeout", "timed out", "rate limit")):
return "transient"
if any(kw in lower_msg for kw in ("unauthorized", "forbidden")):
return "auth"
if any(kw in lower_msg for kw in ("invalid", "missing", "bad request")):
return "validation"
return "unknown"
def is_retryable(category: MCP_ERROR_CATEGORY) -> bool:
"""Return whether a given error category is retryable."""
return category == "transient"
def max_retries() -> int:
"""Maximum retry attempts for transient errors."""
return _MAX_RETRIES

View File

@@ -0,0 +1,85 @@
"""Tests for app.safety module -- confirmation rules and MCP error taxonomy."""
from __future__ import annotations
import pytest
from app.safety import (
classify_mcp_error,
is_retryable,
max_retries,
requires_confirmation,
)
pytestmark = pytest.mark.unit
class TestRequiresConfirmation:
def test_read_agent_no_override(self) -> None:
result = requires_confirmation(agent_permission="read")
assert result.requires_confirmation is False
def test_write_agent_no_override(self) -> None:
result = requires_confirmation(agent_permission="write")
assert result.requires_confirmation is True
def test_interrupt_override_true(self) -> None:
result = requires_confirmation(
agent_permission="read", needs_interrupt=True,
)
assert result.requires_confirmation is True
def test_interrupt_override_false(self) -> None:
result = requires_confirmation(
agent_permission="write", needs_interrupt=False,
)
assert result.requires_confirmation is False
class TestClassifyMcpError:
@pytest.mark.parametrize("code", [408, 429, 500, 502, 503, 504])
def test_transient_status_codes(self, code: int) -> None:
assert classify_mcp_error(status_code=code) == "transient"
@pytest.mark.parametrize("code", [401, 403])
def test_auth_status_codes(self, code: int) -> None:
assert classify_mcp_error(status_code=code) == "auth"
@pytest.mark.parametrize("code", [400, 404, 422])
def test_validation_status_codes(self, code: int) -> None:
assert classify_mcp_error(status_code=code) == "validation"
def test_unknown_status_code(self) -> None:
assert classify_mcp_error(status_code=200) == "unknown"
def test_timeout_message(self) -> None:
assert classify_mcp_error(error_message="Connection timed out") == "transient"
def test_rate_limit_message(self) -> None:
assert classify_mcp_error(error_message="Rate limit exceeded") == "transient"
def test_unauthorized_message(self) -> None:
assert classify_mcp_error(error_message="Unauthorized access") == "auth"
def test_invalid_message(self) -> None:
assert classify_mcp_error(error_message="Invalid parameter") == "validation"
def test_unknown_message(self) -> None:
assert classify_mcp_error(error_message="Something happened") == "unknown"
class TestRetryPolicy:
def test_transient_is_retryable(self) -> None:
assert is_retryable("transient") is True
def test_validation_not_retryable(self) -> None:
assert is_retryable("validation") is False
def test_auth_not_retryable(self) -> None:
assert is_retryable("auth") is False
def test_unknown_not_retryable(self) -> None:
assert is_retryable("unknown") is False
def test_max_retries_value(self) -> None:
assert max_retries() == 3

View File

@@ -658,6 +658,140 @@ body {
border-color: var(--text-primary); border-color: var(--text-primary);
} }
/* --- Shared Data Display Components --- */
.section-card {
background-color: var(--bg-surface);
border-radius: var(--radius-xl);
padding: 1.5rem;
border: 1px solid var(--border-light);
}
.stat-label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.05em;
}
.stat-value {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary);
}
.status-badge {
display: inline-block;
font-size: 0.75rem;
padding: 4px 10px;
border-radius: 6px;
font-weight: 600;
}
.status-badge--resolved {
background-color: #DEF7EC;
color: #03543F;
}
.status-badge--escalated {
background-color: #FDE8E8;
color: #9B1C1C;
}
.status-badge--active {
background-color: var(--bg-hover);
color: var(--text-secondary);
}
.data-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.data-table th {
padding: 0.75rem 1.5rem;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: 600;
}
.data-table td {
padding: 1.25rem 1.5rem;
font-size: 0.9375rem;
}
.data-table thead tr {
border-bottom: 2px solid var(--border-light);
}
.data-table tbody tr {
border-bottom: 1px solid var(--border-light);
transition: background-color 0.2s;
}
.data-table tbody tr:last-child {
border-bottom: none;
}
.data-table tbody tr:hover {
background-color: var(--bg-hover);
}
.empty-state {
padding: 3rem;
text-align: center;
color: var(--text-secondary);
}
.empty-state__title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.empty-state__description {
margin-top: 0.5rem;
}
.error-state {
padding: 3rem;
text-align: center;
color: var(--text-secondary);
}
.error-state__title {
font-size: 1.125rem;
font-weight: 600;
color: var(--brand-accent);
margin: 0;
}
.error-state__description {
margin-top: 0.5rem;
}
.pagination-bar {
padding: 1.25rem 1.5rem;
border-top: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--bg-surface-inner);
}
.pagination-bar__info {
font-size: 0.875rem;
color: var(--text-secondary);
}
.pagination-bar__controls {
display: flex;
gap: 0.5rem;
}
/* --- Skeleton Loading Animation --- */ /* --- Skeleton Loading Animation --- */
@keyframes pulse-skeleton { @keyframes pulse-skeleton {
0% { opacity: 0.5; background-color: var(--bg-hover); } 0% { opacity: 0.5; background-color: var(--bg-hover); }

View File

@@ -65,7 +65,7 @@ export function DashboardPage() {
<> <>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
{[1, 2, 3, 4].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 key={i} className="skeleton-box section-card" style={{ height: "120px" }}>
<div className="skeleton-text" style={{ width: "60%", height: "12px", marginBottom: "1.5rem" }}></div> <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> <div className="skeleton-text" style={{ width: "40%", height: "30px", marginBottom: "1rem" }}></div>
<div className="skeleton-text" style={{ width: "80%", height: "12px" }}></div> <div className="skeleton-text" style={{ width: "80%", height: "12px" }}></div>
@@ -78,15 +78,15 @@ export function DashboardPage() {
</div> </div>
</> </>
) : error ? ( ) : error ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}> <div className="error-state">
<p style={{ fontSize: "1.125rem", fontWeight: 600, color: "var(--brand-accent)" }}>Failed to load analytics</p> <p className="error-state__title">Failed to load analytics</p>
<p style={{ marginTop: "0.5rem" }}>{error}</p> <p className="error-state__description">{error}</p>
<button onClick={() => setRange(range)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button> <button onClick={() => setRange(range)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button>
</div> </div>
) : !data ? ( ) : !data ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}> <div className="empty-state">
<p style={{ fontSize: "1.125rem", fontWeight: 600 }}>No analytics data available</p> <p className="empty-state__title">No analytics data available</p>
<p style={{ marginTop: "0.5rem" }}>Start some conversations to see metrics here.</p> <p className="empty-state__description">Start some conversations to see metrics here.</p>
</div> </div>
) : ( ) : (
<> <>
@@ -99,25 +99,25 @@ export function DashboardPage() {
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}> <div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
{/* Agent Workload Table */} {/* Agent Workload Table */}
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}> <div className="section-card">
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: "0 0 1rem 0" }}>Agent Workload Distribution</h3> <h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: "0 0 1rem 0" }}>Agent Workload Distribution</h3>
{data.agent_usage.length === 0 ? ( {data.agent_usage.length === 0 ? (
<p style={{ color: "var(--text-secondary)", fontSize: "0.875rem" }}>No agent activity recorded yet.</p> <p style={{ color: "var(--text-secondary)", fontSize: "0.875rem" }}>No agent activity recorded yet.</p>
) : ( ) : (
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}> <table className="data-table">
<thead> <thead>
<tr style={{ borderBottom: "2px solid var(--border-light)" }}> <tr>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Agent Name</th> <th style={{ paddingLeft: 0 }}>Agent Name</th>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Message Count</th> <th>Message Count</th>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Share</th> <th>Share</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.agent_usage.map((a) => ( {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'}> <tr key={a.agent}>
<td style={{ padding: "1rem 0 1rem 1rem", fontWeight: 600, fontSize: "0.9375rem" }}>{a.agent}</td> <td style={{ paddingLeft: 0, fontWeight: 600 }}>{a.agent}</td>
<td style={{ padding: "1rem 0", fontSize: "0.9375rem" }}>{a.count.toLocaleString()}</td> <td>{a.count.toLocaleString()}</td>
<td style={{ padding: "1rem 1rem 1rem 0", fontSize: "0.9375rem" }}>{pct(a.percentage)}</td> <td>{pct(a.percentage)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -126,7 +126,7 @@ export function DashboardPage() {
</div> </div>
{/* Human in the loop card */} {/* Human in the loop card */}
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}> <div className="section-card">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}>
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: 0 }}>Security Approvals</h3> <h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: 0 }}>Security Approvals</h3>
<span title="Actions requiring human review before proceeding" style={{ cursor: "help", color: "var(--text-secondary)", fontSize: "0.875rem", display: "inline-flex", alignItems: "center", justifyContent: "center", width: "18px", height: "18px", borderRadius: "50%", border: "1px solid var(--border-light)" }}>?</span> <span title="Actions requiring human review before proceeding" style={{ cursor: "help", color: "var(--text-secondary)", fontSize: "0.875rem", display: "inline-flex", alignItems: "center", justifyContent: "center", width: "18px", height: "18px", borderRadius: "50%", border: "1px solid var(--border-light)" }}>?</span>
@@ -167,24 +167,10 @@ export function DashboardPage() {
function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) { function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) {
return ( return (
<div style={{ <div className="section-card" style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
backgroundColor: "var(--bg-surface)", <div className="stat-label">{label}</div>
padding: "1.5rem", <div style={{ fontSize: "2rem", fontWeight: 700, color: "var(--text-primary)" }}>{value}</div>
borderRadius: "var(--radius-xl)", <div style={{ fontSize: "0.8125rem", color: positive ? "#059669" : "var(--text-secondary)", fontWeight: positive ? 600 : 400 }}>{trend}</div>
border: "1px solid var(--border-light)",
display: "flex",
flexDirection: "column",
gap: "0.5rem"
}}>
<div style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600 }}>
{label}
</div>
<div style={{ fontSize: "2rem", fontWeight: 700, color: "var(--text-primary)" }}>
{value}
</div>
<div style={{ fontSize: "0.8125rem", color: positive ? "#059669" : "var(--text-secondary)", fontWeight: positive ? 600 : 400 }}>
{trend}
</div>
</div> </div>
); );
} }

View File

@@ -37,23 +37,27 @@ export function ReplayListPage() {
return `$${usd.toFixed(2)}`; return `$${usd.toFixed(2)}`;
} }
function statusClass(status: string | null): string {
if (status === "resolved") return "status-badge status-badge--resolved";
if (status === "escalated") return "status-badge status-badge--escalated";
return "status-badge status-badge--active";
}
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}> <div className="page-header">
<div> <h2>Conversation Replay</h2>
<h2>Conversation Replay</h2> <p>Review autonomous agent sessions and audit MCP action execution trails.</p>
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
</div>
</div> </div>
{error ? ( {error ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}> <div className="error-state">
<p style={{ fontSize: "1.125rem", fontWeight: 600, color: "var(--brand-accent)" }}>Failed to load conversations</p> <p className="error-state__title">Failed to load conversations</p>
<p style={{ marginTop: "0.5rem" }}>{error}</p> <p className="error-state__description">{error}</p>
<button onClick={() => setPage(1)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button> <button onClick={() => setPage(1)} className="btn btn-secondary" style={{ marginTop: "1rem" }}>Retry</button>
</div> </div>
) : isLoading ? ( ) : isLoading ? (
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)", padding: "2rem" }}> <div className="section-card" style={{ padding: "2rem" }}>
{[1, 2, 3, 4, 5].map(i => ( {[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-box" style={{ height: "60px", marginBottom: "1rem", borderRadius: "8px" }}> <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 className="skeleton-text" style={{ width: "30%", height: "14px", margin: "12px 16px" }}></div>
@@ -61,56 +65,38 @@ export function ReplayListPage() {
))} ))}
</div> </div>
) : conversations.length === 0 ? ( ) : conversations.length === 0 ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}> <div className="empty-state">
<p style={{ fontSize: "1.125rem", fontWeight: 600 }}>No conversations yet</p> <p className="empty-state__title">No conversations yet</p>
<p style={{ marginTop: "0.5rem" }}>Start a chat session to see conversations here.</p> <p className="empty-state__description">Start a chat session to see conversations here.</p>
</div> </div>
) : ( ) : (
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)" }}> <div className="section-card" style={{ padding: 0, overflow: "hidden" }}>
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}> <table className="data-table">
<thead> <thead>
<tr style={{ backgroundColor: "var(--bg-surface-inner)", borderBottom: "1px solid var(--border-light)" }}> <tr style={{ backgroundColor: "var(--bg-surface-inner)" }}>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread</th> <th>Thread</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Created</th> <th>Created</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Last Activity</th> <th>Last Activity</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Status</th> <th>Status</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Cost</th> <th>Cost</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{conversations.map((c, i) => ( {conversations.map((c) => (
<tr <tr
key={c.thread_id} key={c.thread_id}
onClick={() => navigate(`/replay/${c.thread_id}`)} onClick={() => navigate(`/replay/${c.thread_id}`)}
style={{ style={{ cursor: "pointer" }}
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" }}> <td>
<div style={{ fontWeight: 600, color: "var(--text-primary)", fontSize: "0.9375rem", fontFamily: "monospace" }}>{c.thread_id}</div> <span style={{ fontWeight: 600, fontFamily: "monospace" }}>{c.thread_id}</span>
</td> </td>
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}> <td style={{ color: "var(--text-secondary)" }}>{formatDate(c.created_at)}</td>
{formatDate(c.created_at)} <td style={{ color: "var(--text-secondary)" }}>{formatDate(c.last_activity)}</td>
<td>
<span className={statusClass(c.status)}>{c.status ?? "active"}</span>
</td> </td>
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}> <td style={{ 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)} {c.total_tokens.toLocaleString()} tokens / {formatCost(c.total_cost_usd)}
</td> </td>
</tr> </tr>
@@ -118,11 +104,11 @@ export function ReplayListPage() {
</tbody> </tbody>
</table> </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)" }}> <div className="pagination-bar">
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}> <span className="pagination-bar__info">
Showing {(page - 1) * perPage + 1}-{Math.min(page * perPage, total)} of {total} sessions Showing {(page - 1) * perPage + 1}-{Math.min(page * perPage, total)} of {total} sessions
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> <div className="pagination-bar__controls">
<button <button
onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }} onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }}
disabled={page === 1} disabled={page === 1}
@@ -141,11 +127,6 @@ export function ReplayListPage() {
</div> </div>
</div> </div>
)} )}
<style>{`
.replay-row-hover:hover {
background-color: var(--bg-hover) !important;
}
`}</style>
</div> </div>
); );
} }

View File

@@ -28,23 +28,21 @@ export function ReplayPage() {
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}> <div className="page-header" style={{ marginBottom: "2rem" }}>
<div> <button
<button onClick={() => navigate("/replay")}
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" }}
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" }} >
> &larr; Back to All Replays
&larr; Back to All Replays </button>
</button> <h2>Audit Trail: <span style={{ fontFamily: "monospace", color: "var(--brand-primary)" }}>{threadId}</span></h2>
<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>
<p>Detailed temporal log of agent reflections, MCP tool calls, and human overrides.</p>
</div>
</div> </div>
{error ? ( {error ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}> <div className="error-state">
<p style={{ fontSize: "1.125rem", fontWeight: 600, color: "var(--brand-accent)" }}>Failed to load replay</p> <p className="error-state__title">Failed to load replay</p>
<p style={{ marginTop: "0.5rem" }}>{error}</p> <p className="error-state__description">{error}</p>
</div> </div>
) : isLoading ? ( ) : isLoading ? (
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
@@ -52,29 +50,29 @@ export function ReplayPage() {
<div className="skeleton-box" style={{ height: "400px", 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> </div>
) : steps.length === 0 ? ( ) : steps.length === 0 ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--text-secondary)" }}> <div className="empty-state">
<p style={{ fontSize: "1.125rem", fontWeight: 600 }}>No replay steps found</p> <p className="empty-state__title">No replay steps found</p>
<p style={{ marginTop: "0.5rem" }}>This conversation has no recorded checkpoints.</p> <p className="empty-state__description">This conversation has no recorded checkpoints.</p>
</div> </div>
) : ( ) : (
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
{/* Sidebar Summary Info */} {/* Sidebar Summary Info */}
<div style={{ backgroundColor: "var(--bg-surface)", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", alignSelf: "start" }}> <div className="section-card" style={{ alignSelf: "start" }}>
<h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3> <h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}> <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div> <div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread ID</div> <div className="stat-label">Thread ID</div>
<div style={{ fontWeight: 600, fontSize: "0.8125rem", fontFamily: "monospace", wordBreak: "break-all" }}>{threadId}</div> <div className="stat-value" style={{ fontSize: "0.8125rem", fontFamily: "monospace", wordBreak: "break-all" }}>{threadId}</div>
</div> </div>
<div> <div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Total Steps</div> <div className="stat-label">Total Steps</div>
<div style={{ fontSize: "0.9375rem" }}>{totalSteps}</div> <div className="stat-value">{totalSteps}</div>
</div> </div>
<div> <div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Time Range</div> <div className="stat-label">Time Range</div>
<div style={{ fontSize: "0.8125rem" }}> <div style={{ fontSize: "0.8125rem" }}>
{steps[0]?.timestamp ? new Date(steps[0].timestamp).toLocaleString() : "N/A"} {steps[0]?.timestamp ? new Date(steps[0].timestamp).toLocaleString() : "N/A"}
{" - "} {" \u2013 "}
{steps[steps.length - 1]?.timestamp ? new Date(steps[steps.length - 1].timestamp).toLocaleString() : "N/A"} {steps[steps.length - 1]?.timestamp ? new Date(steps[steps.length - 1].timestamp).toLocaleString() : "N/A"}
</div> </div>
</div> </div>
@@ -82,7 +80,7 @@ export function ReplayPage() {
</div> </div>
{/* Timeline */} {/* Timeline */}
<div style={{ backgroundColor: "var(--bg-surface)", padding: "2rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)" }}> <div className="section-card" style={{ padding: "2rem" }}>
<ReplayTimeline steps={steps as any} /> <ReplayTimeline steps={steps as any} />
</div> </div>
</div> </div>