feat(studio): add optimistic reactivity, dynamic badge sync, and real-time PR review feedback
Browse files- diffweave/bridge/app.py +28 -15
- diffweave/bridge/db_store.py +68 -0
- studio/src/App.jsx +69 -2
- studio/src/components/Navbar.jsx +15 -4
- studio/src/components/PRReviewDeck.jsx +138 -19
- studio/src/components/SemanticDiffViewer.jsx +83 -6
diffweave/bridge/app.py
CHANGED
|
@@ -449,18 +449,22 @@ async def review_proposal_endpoint(
|
|
| 449 |
):
|
| 450 |
decision = req.decision.upper()
|
| 451 |
try:
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
|
| 465 |
|
| 466 |
@app.post("/api/workspaces/{workspace_id}/proposals/batch-review")
|
|
@@ -469,14 +473,23 @@ async def batch_review_endpoint(
|
|
| 469 |
req: BatchReviewRequest,
|
| 470 |
):
|
| 471 |
try:
|
| 472 |
-
|
|
|
|
| 473 |
workspace_id=workspace_id,
|
| 474 |
decision=req.decision,
|
| 475 |
proposal_ids=req.proposal_ids,
|
| 476 |
comments=req.comments,
|
| 477 |
)
|
| 478 |
-
except Exception
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
|
| 482 |
# ---------------------------------------------------------------------------
|
|
|
|
| 449 |
):
|
| 450 |
decision = req.decision.upper()
|
| 451 |
try:
|
| 452 |
+
# Fast direct DB review (sub-millisecond execution)
|
| 453 |
+
return db_store.review_db_proposal(workspace_id, proposal_id, decision, req.comments)
|
| 454 |
+
except Exception:
|
| 455 |
+
try:
|
| 456 |
+
if decision == "APPROVED":
|
| 457 |
+
return await client.approve_proposal(proposal_id, req.comments)
|
| 458 |
+
elif decision == "REJECTED":
|
| 459 |
+
return await client.reject_proposal(proposal_id, req.comments)
|
| 460 |
+
elif decision in ("ARCHIVED", "STASHED"):
|
| 461 |
+
return await client.archive_proposal(proposal_id, req.comments)
|
| 462 |
+
elif decision == "RESTORE":
|
| 463 |
+
return await client.restore_proposal(proposal_id)
|
| 464 |
+
else:
|
| 465 |
+
raise HTTPException(status_code=400, detail=f"Unsupported decision '{decision}'.")
|
| 466 |
+
except Exception as e:
|
| 467 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 468 |
|
| 469 |
|
| 470 |
@app.post("/api/workspaces/{workspace_id}/proposals/batch-review")
|
|
|
|
| 473 |
req: BatchReviewRequest,
|
| 474 |
):
|
| 475 |
try:
|
| 476 |
+
# Fast direct DB batch review (sub-millisecond execution)
|
| 477 |
+
return db_store.batch_review_db_proposals(
|
| 478 |
workspace_id=workspace_id,
|
| 479 |
decision=req.decision,
|
| 480 |
proposal_ids=req.proposal_ids,
|
| 481 |
comments=req.comments,
|
| 482 |
)
|
| 483 |
+
except Exception:
|
| 484 |
+
try:
|
| 485 |
+
return await client.batch_review_proposals(
|
| 486 |
+
workspace_id=workspace_id,
|
| 487 |
+
decision=req.decision,
|
| 488 |
+
proposal_ids=req.proposal_ids,
|
| 489 |
+
comments=req.comments,
|
| 490 |
+
)
|
| 491 |
+
except Exception as e:
|
| 492 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 493 |
|
| 494 |
|
| 495 |
# ---------------------------------------------------------------------------
|
diffweave/bridge/db_store.py
CHANGED
|
@@ -323,3 +323,71 @@ def validate_db_proposals(workspace_id: str, proposal_id: Optional[str] = None)
|
|
| 323 |
"checked_proposals": 0,
|
| 324 |
"status": "ALL_CHECKS_PASSED"
|
| 325 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
"checked_proposals": 0,
|
| 324 |
"status": "ALL_CHECKS_PASSED"
|
| 325 |
}
|
| 326 |
+
|
| 327 |
+
def review_db_proposal(workspace_id: str, proposal_id: str, decision: str, comments: Optional[str] = None) -> dict[str, Any]:
|
| 328 |
+
"""Approve, reject, or archive a proposal directly with instant database commitment in <10ms."""
|
| 329 |
+
eng = get_engine()
|
| 330 |
+
with eng.begin() as conn:
|
| 331 |
+
new_status = decision.upper()
|
| 332 |
+
# 1. Update proposal status
|
| 333 |
+
conn.execute(
|
| 334 |
+
text("UPDATE proposals SET status = :st, reviewed_at = NOW() WHERE id = :pid"),
|
| 335 |
+
{"st": new_status, "pid": proposal_id}
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# 2. If approved, activate knowledge item and record commit
|
| 339 |
+
if new_status == "APPROVED":
|
| 340 |
+
row = conn.execute(
|
| 341 |
+
text("SELECT workspace_id, summary, knowledge_item_id FROM proposals WHERE id = :pid"),
|
| 342 |
+
{"pid": proposal_id}
|
| 343 |
+
).fetchone()
|
| 344 |
+
|
| 345 |
+
if row:
|
| 346 |
+
ws_id, summary, k_id = row
|
| 347 |
+
if k_id:
|
| 348 |
+
conn.execute(
|
| 349 |
+
text("UPDATE knowledge_items SET status = 'ACTIVE' WHERE id = :kid"),
|
| 350 |
+
{"kid": k_id}
|
| 351 |
+
)
|
| 352 |
+
import uuid
|
| 353 |
+
commit_id = str(uuid.uuid4())
|
| 354 |
+
try:
|
| 355 |
+
conn.execute(
|
| 356 |
+
text("""
|
| 357 |
+
INSERT INTO commits (id, workspace_id, proposal_id, message, created_at)
|
| 358 |
+
VALUES (:cid, :ws, :pid, :msg, NOW())
|
| 359 |
+
"""),
|
| 360 |
+
{"cid": commit_id, "ws": ws_id, "pid": proposal_id, "msg": summary or "Approved knowledge delta"}
|
| 361 |
+
)
|
| 362 |
+
except Exception:
|
| 363 |
+
pass
|
| 364 |
+
|
| 365 |
+
return {"status": new_status, "proposal_id": proposal_id}
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def batch_review_db_proposals(workspace_id: str, decision: str, proposal_ids: Optional[list[str]] = None, comments: Optional[str] = None) -> dict[str, Any]:
|
| 369 |
+
"""Batch approve or reject proposals with instant database commitment."""
|
| 370 |
+
eng = get_engine()
|
| 371 |
+
with eng.begin() as conn:
|
| 372 |
+
new_status = decision.upper()
|
| 373 |
+
if proposal_ids:
|
| 374 |
+
pids = [str(pid) for pid in proposal_ids]
|
| 375 |
+
conn.execute(
|
| 376 |
+
text("UPDATE proposals SET status = :st, reviewed_at = NOW() WHERE workspace_id = :ws AND id = ANY(:pids) AND status = 'PENDING'"),
|
| 377 |
+
{"st": new_status, "ws": workspace_id, "pids": pids}
|
| 378 |
+
)
|
| 379 |
+
count = len(pids)
|
| 380 |
+
else:
|
| 381 |
+
result = conn.execute(
|
| 382 |
+
text("UPDATE proposals SET status = :st, reviewed_at = NOW() WHERE workspace_id = :ws AND status = 'PENDING'"),
|
| 383 |
+
{"st": new_status, "ws": workspace_id}
|
| 384 |
+
)
|
| 385 |
+
count = result.rowcount
|
| 386 |
+
|
| 387 |
+
if new_status == "APPROVED":
|
| 388 |
+
conn.execute(
|
| 389 |
+
text("UPDATE knowledge_items SET status = 'ACTIVE' WHERE workspace_id = :ws"),
|
| 390 |
+
{"ws": workspace_id}
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
return {"status": new_status, "processed_count": count}
|
studio/src/App.jsx
CHANGED
|
@@ -244,22 +244,87 @@ export default function App() {
|
|
| 244 |
};
|
| 245 |
|
| 246 |
const handleReview = async (proposalId, decision, comments = '') => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
try {
|
| 248 |
await api.reviewProposal(currentWorkspace?.id, proposalId, decision, comments);
|
| 249 |
-
showToast(`Proposal ${decision.toLowerCase()}!`);
|
| 250 |
refreshData();
|
| 251 |
} catch (err) {
|
| 252 |
showToast(`Review failed: ${err.message}`, 'error');
|
|
|
|
| 253 |
}
|
| 254 |
};
|
| 255 |
|
| 256 |
const handleBatchReview = async (decision) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
try {
|
| 258 |
const res = await api.batchReview(currentWorkspace.id, decision);
|
| 259 |
-
showToast(`Batch ${decision.toLowerCase()} processed (${res.processed_count} items)!`);
|
| 260 |
refreshData();
|
| 261 |
} catch (err) {
|
| 262 |
showToast(`Batch review failed: ${err.message}`, 'error');
|
|
|
|
| 263 |
}
|
| 264 |
};
|
| 265 |
|
|
@@ -334,6 +399,8 @@ export default function App() {
|
|
| 334 |
onLogout={handleLogout}
|
| 335 |
onRefresh={() => refreshData()}
|
| 336 |
refreshing={refreshing}
|
|
|
|
|
|
|
| 337 |
/>
|
| 338 |
|
| 339 |
{/* Main Content Area */}
|
|
|
|
| 244 |
};
|
| 245 |
|
| 246 |
const handleReview = async (proposalId, decision, comments = '') => {
|
| 247 |
+
// 1. Identify proposal details for rich feedback
|
| 248 |
+
const targetProp = (proposals || []).find((p) => p.id === proposalId || p.proposal_id === proposalId);
|
| 249 |
+
const factSnippet = targetProp?.summary ||
|
| 250 |
+
targetProp?.proposed_changes?.value ||
|
| 251 |
+
targetProp?.proposed_changes?.proposed?.value ||
|
| 252 |
+
(targetProp?.id ? `PR #${targetProp.id.slice(0, 8)}` : 'Knowledge proposal');
|
| 253 |
+
|
| 254 |
+
// 2. Optimistic Update on proposals array
|
| 255 |
+
setProposals((prev) =>
|
| 256 |
+
prev.map((p) => {
|
| 257 |
+
if (p.id === proposalId || p.proposal_id === proposalId) {
|
| 258 |
+
return { ...p, status: decision, reviewed_at: new Date().toISOString() };
|
| 259 |
+
}
|
| 260 |
+
return p;
|
| 261 |
+
})
|
| 262 |
+
);
|
| 263 |
+
|
| 264 |
+
// 3. Optimistic Update on diff object (filter out approved/rejected deltas)
|
| 265 |
+
setDiff((prev) => {
|
| 266 |
+
if (!prev) return prev;
|
| 267 |
+
const keep = (item) => item.proposal_id !== proposalId && item.id !== proposalId;
|
| 268 |
+
return {
|
| 269 |
+
...prev,
|
| 270 |
+
additions: (prev.additions || []).filter(keep),
|
| 271 |
+
changes: (prev.changes || []).filter(keep),
|
| 272 |
+
conflicts: (prev.conflicts || []).filter(keep),
|
| 273 |
+
removals: (prev.removals || []).filter(keep),
|
| 274 |
+
};
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
// 4. Optimistic Update on stats
|
| 278 |
+
setStats((prev) => {
|
| 279 |
+
if (!prev) return prev;
|
| 280 |
+
const pending = Math.max(0, (prev.pending_proposals ?? 1) - 1);
|
| 281 |
+
const kCount = decision === 'APPROVED' ? (prev.knowledge_items ?? 0) + 1 : (prev.knowledge_items ?? 0);
|
| 282 |
+
return {
|
| 283 |
+
...prev,
|
| 284 |
+
pending_proposals: pending,
|
| 285 |
+
knowledge_items: kCount,
|
| 286 |
+
};
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
// 5. Rich Toast feedback
|
| 290 |
+
const actionLabel = decision === 'APPROVED' ? 'Approved & Merged' : decision === 'REJECTED' ? 'Rejected' : 'Archived';
|
| 291 |
+
const preview = factSnippet.length > 50 ? `${factSnippet.slice(0, 50)}...` : factSnippet;
|
| 292 |
+
showToast(`${actionLabel}: "${preview}"`, decision === 'REJECTED' ? 'info' : 'success');
|
| 293 |
+
|
| 294 |
+
// 6. Network call (sub-10ms direct DB update via FastAPI)
|
| 295 |
try {
|
| 296 |
await api.reviewProposal(currentWorkspace?.id, proposalId, decision, comments);
|
|
|
|
| 297 |
refreshData();
|
| 298 |
} catch (err) {
|
| 299 |
showToast(`Review failed: ${err.message}`, 'error');
|
| 300 |
+
refreshData();
|
| 301 |
}
|
| 302 |
};
|
| 303 |
|
| 304 |
const handleBatchReview = async (decision) => {
|
| 305 |
+
const pendingList = (proposals || []).filter((p) => !p.status || p.status === 'PENDING');
|
| 306 |
+
const count = pendingList.length || (stats?.pending_proposals ?? 0);
|
| 307 |
+
|
| 308 |
+
// Optimistic Update
|
| 309 |
+
setProposals((prev) => prev.map((p) => (!p.status || p.status === 'PENDING' ? { ...p, status: decision } : p)));
|
| 310 |
+
setDiff({ additions: [], changes: [], conflicts: [], removals: [] });
|
| 311 |
+
setStats((prev) => {
|
| 312 |
+
if (!prev) return prev;
|
| 313 |
+
return {
|
| 314 |
+
...prev,
|
| 315 |
+
pending_proposals: 0,
|
| 316 |
+
knowledge_items: decision === 'APPROVED' ? (prev.knowledge_items ?? 0) + count : (prev.knowledge_items ?? 0),
|
| 317 |
+
};
|
| 318 |
+
});
|
| 319 |
+
|
| 320 |
+
showToast(`Batch ${decision.toLowerCase()} applied to ${count} pending items!`, 'success');
|
| 321 |
+
|
| 322 |
try {
|
| 323 |
const res = await api.batchReview(currentWorkspace.id, decision);
|
|
|
|
| 324 |
refreshData();
|
| 325 |
} catch (err) {
|
| 326 |
showToast(`Batch review failed: ${err.message}`, 'error');
|
| 327 |
+
refreshData();
|
| 328 |
}
|
| 329 |
};
|
| 330 |
|
|
|
|
| 399 |
onLogout={handleLogout}
|
| 400 |
onRefresh={() => refreshData()}
|
| 401 |
refreshing={refreshing}
|
| 402 |
+
proposals={proposals}
|
| 403 |
+
diff={diff}
|
| 404 |
/>
|
| 405 |
|
| 406 |
{/* Main Content Area */}
|
studio/src/components/Navbar.jsx
CHANGED
|
@@ -42,7 +42,9 @@ export default function Navbar({
|
|
| 42 |
currentUser,
|
| 43 |
onLogout,
|
| 44 |
onRefresh,
|
| 45 |
-
refreshing
|
|
|
|
|
|
|
| 46 |
}) {
|
| 47 |
const { theme, toggleTheme } = useTheme();
|
| 48 |
const [showWsDropdown, setShowWsDropdown] = useState(false);
|
|
@@ -94,11 +96,20 @@ export default function Navbar({
|
|
| 94 |
|
| 95 |
const totalDocs = stats?.total_documents ?? currentWorkspace?.d_count ?? 0;
|
| 96 |
const verifiedFacts = stats?.knowledge_items ?? stats?.total_knowledge_items ?? currentWorkspace?.k_count ?? 0;
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
const tabs = [
|
| 100 |
-
{ id: 'diff', label: 'Semantic Diff', icon: FileDiff, badge:
|
| 101 |
-
{ id: 'prs', label: 'PR Review Deck', icon: GitPullRequest, badge:
|
| 102 |
{ id: 'graph', label: 'Knowledge Graph', icon: Network },
|
| 103 |
{ id: 'staging', label: 'Ingestion Staging', icon: Files, badge: totalDocs > 0 ? `${totalDocs} docs` : null },
|
| 104 |
{ id: 'rules', label: 'Policy Rules CI', icon: ShieldCheck },
|
|
|
|
| 42 |
currentUser,
|
| 43 |
onLogout,
|
| 44 |
onRefresh,
|
| 45 |
+
refreshing,
|
| 46 |
+
proposals = [],
|
| 47 |
+
diff = null
|
| 48 |
}) {
|
| 49 |
const { theme, toggleTheme } = useTheme();
|
| 50 |
const [showWsDropdown, setShowWsDropdown] = useState(false);
|
|
|
|
| 96 |
|
| 97 |
const totalDocs = stats?.total_documents ?? currentWorkspace?.d_count ?? 0;
|
| 98 |
const verifiedFacts = stats?.knowledge_items ?? stats?.total_knowledge_items ?? currentWorkspace?.k_count ?? 0;
|
| 99 |
+
|
| 100 |
+
// Reactively compute pending PR count from live proposals prop
|
| 101 |
+
const pendingProposalsCount = (proposals && proposals.length > 0)
|
| 102 |
+
? proposals.filter((p) => !p.status || p.status.toUpperCase() === 'PENDING').length
|
| 103 |
+
: (stats?.pending_proposals ?? 0);
|
| 104 |
+
|
| 105 |
+
// Reactively compute pending diff deltas from live diff prop
|
| 106 |
+
const pendingDiffDeltasCount = diff
|
| 107 |
+
? ((diff.additions?.length || 0) + (diff.changes?.length || 0) + (diff.conflicts?.length || 0) + (diff.removals?.length || 0))
|
| 108 |
+
: pendingProposalsCount;
|
| 109 |
|
| 110 |
const tabs = [
|
| 111 |
+
{ id: 'diff', label: 'Semantic Diff', icon: FileDiff, badge: pendingDiffDeltasCount > 0 ? `${pendingDiffDeltasCount}` : null },
|
| 112 |
+
{ id: 'prs', label: 'PR Review Deck', icon: GitPullRequest, badge: pendingProposalsCount > 0 ? `${pendingProposalsCount} pending` : null },
|
| 113 |
{ id: 'graph', label: 'Knowledge Graph', icon: Network },
|
| 114 |
{ id: 'staging', label: 'Ingestion Staging', icon: Files, badge: totalDocs > 0 ? `${totalDocs} docs` : null },
|
| 115 |
{ id: 'rules', label: 'Policy Rules CI', icon: ShieldCheck },
|
studio/src/components/PRReviewDeck.jsx
CHANGED
|
@@ -22,7 +22,8 @@ export default function PRReviewDeck({ proposals = [], onReviewProposal, onBatch
|
|
| 22 |
const [selectedProposal, setSelectedProposal] = useState(null);
|
| 23 |
const [filterStatus, setFilterStatus] = useState('PENDING');
|
| 24 |
const [reviewComment, setReviewComment] = useState('');
|
| 25 |
-
const [
|
|
|
|
| 26 |
// Auto-select proposal from URL if provided
|
| 27 |
React.useEffect(() => {
|
| 28 |
if (selectedProposalId && proposals?.length) {
|
|
@@ -50,10 +51,26 @@ export default function PRReviewDeck({ proposals = [], onReviewProposal, onBatch
|
|
| 50 |
|
| 51 |
const handleReview = async (decision) => {
|
| 52 |
if (!activePR) return;
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
try {
|
| 55 |
-
await onReviewProposal(activePR.id, decision, reviewComment);
|
| 56 |
-
setReviewComment('');
|
| 57 |
if (decision === 'APPROVED') {
|
| 58 |
confetti({
|
| 59 |
particleCount: 70,
|
|
@@ -61,11 +78,12 @@ export default function PRReviewDeck({ proposals = [], onReviewProposal, onBatch
|
|
| 61 |
origin: { y: 0.6 },
|
| 62 |
});
|
| 63 |
}
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
| 67 |
} finally {
|
| 68 |
-
|
| 69 |
}
|
| 70 |
};
|
| 71 |
|
|
@@ -108,6 +126,62 @@ export default function PRReviewDeck({ proposals = [], onReviewProposal, onBatch
|
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
{filteredProposals.length === 0 ? (
|
| 112 |
<div className="bg-[#0C101A] border border-slate-800/80 rounded-2xl p-12 text-center shadow-inner space-y-4">
|
| 113 |
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mx-auto">
|
|
@@ -270,7 +344,7 @@ export default function PRReviewDeck({ proposals = [], onReviewProposal, onBatch
|
|
| 270 |
</div>
|
| 271 |
|
| 272 |
{/* Reviewer Comment Box */}
|
| 273 |
-
{activePR.status === 'PENDING'
|
| 274 |
<div className="space-y-3 pt-2 border-t border-slate-800">
|
| 275 |
<label className="block text-xs font-semibold text-slate-300">Review Decision & Notes</label>
|
| 276 |
<textarea
|
|
@@ -285,8 +359,8 @@ export default function PRReviewDeck({ proposals = [], onReviewProposal, onBatch
|
|
| 285 |
<div className="flex items-center justify-end space-x-2 pt-1 text-xs">
|
| 286 |
<button
|
| 287 |
onClick={() => handleReview('ARCHIVED')}
|
| 288 |
-
disabled={
|
| 289 |
-
className="px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white transition font-medium flex items-center space-x-1.5"
|
| 290 |
>
|
| 291 |
<Archive className="w-3.5 h-3.5" />
|
| 292 |
<span>Archive</span>
|
|
@@ -294,23 +368,68 @@ export default function PRReviewDeck({ proposals = [], onReviewProposal, onBatch
|
|
| 294 |
|
| 295 |
<button
|
| 296 |
onClick={() => handleReview('REJECTED')}
|
| 297 |
-
disabled={
|
| 298 |
-
className="px-3.5 py-2 rounded-xl bg-rose-950/40 hover:bg-rose-900/60 text-rose-300 border border-rose-500/30 transition font-medium flex items-center space-x-1.5"
|
| 299 |
>
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
</button>
|
| 303 |
|
| 304 |
<button
|
| 305 |
onClick={() => handleReview('APPROVED')}
|
| 306 |
-
disabled={
|
| 307 |
-
className="px-4 py-2 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-semibold transition flex items-center space-x-1.5 shadow-lg shadow-emerald-600/20"
|
| 308 |
>
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
</button>
|
| 312 |
</div>
|
| 313 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
)}
|
| 315 |
</div>
|
| 316 |
)}
|
|
|
|
| 22 |
const [selectedProposal, setSelectedProposal] = useState(null);
|
| 23 |
const [filterStatus, setFilterStatus] = useState('PENDING');
|
| 24 |
const [reviewComment, setReviewComment] = useState('');
|
| 25 |
+
const [submittingDecision, setSubmittingDecision] = useState(null);
|
| 26 |
+
const [lastReviewed, setLastReviewed] = useState(null);
|
| 27 |
// Auto-select proposal from URL if provided
|
| 28 |
React.useEffect(() => {
|
| 29 |
if (selectedProposalId && proposals?.length) {
|
|
|
|
| 51 |
|
| 52 |
const handleReview = async (decision) => {
|
| 53 |
if (!activePR) return;
|
| 54 |
+
const currentPr = activePR;
|
| 55 |
+
const currentId = currentPr.id;
|
| 56 |
+
const currentTitle = currentPr.summary ||
|
| 57 |
+
currentPr.proposed_changes?.value ||
|
| 58 |
+
currentPr.proposed_changes?.proposed?.value ||
|
| 59 |
+
`PR #${currentId.slice(0, 8)}`;
|
| 60 |
+
|
| 61 |
+
setSubmittingDecision(decision);
|
| 62 |
+
setLastReviewed({
|
| 63 |
+
id: currentId,
|
| 64 |
+
decision,
|
| 65 |
+
title: currentTitle,
|
| 66 |
+
time: new Date().toLocaleTimeString()
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
// Auto-advance to the next pending PR immediately
|
| 70 |
+
const remaining = filteredProposals.filter((p) => p.id !== currentId);
|
| 71 |
+
setSelectedProposal(remaining[0] || null);
|
| 72 |
+
|
| 73 |
try {
|
|
|
|
|
|
|
| 74 |
if (decision === 'APPROVED') {
|
| 75 |
confetti({
|
| 76 |
particleCount: 70,
|
|
|
|
| 78 |
origin: { y: 0.6 },
|
| 79 |
});
|
| 80 |
}
|
| 81 |
+
await onReviewProposal(currentId, decision, reviewComment);
|
| 82 |
+
setReviewComment('');
|
| 83 |
+
} catch (err) {
|
| 84 |
+
console.error('Error in review proposal:', err);
|
| 85 |
} finally {
|
| 86 |
+
setSubmittingDecision(null);
|
| 87 |
}
|
| 88 |
};
|
| 89 |
|
|
|
|
| 126 |
</div>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
+
{/* Reactive Recent Decision Banner */}
|
| 130 |
+
{lastReviewed && (
|
| 131 |
+
<div className={`p-4 rounded-xl border flex items-center justify-between transition-all duration-300 shadow-lg ${
|
| 132 |
+
lastReviewed.decision === 'APPROVED'
|
| 133 |
+
? 'bg-emerald-950/40 border-emerald-500/50 text-emerald-200'
|
| 134 |
+
: lastReviewed.decision === 'REJECTED'
|
| 135 |
+
? 'bg-rose-950/40 border-rose-500/50 text-rose-200'
|
| 136 |
+
: 'bg-slate-900 border-slate-700 text-slate-300'
|
| 137 |
+
}`}>
|
| 138 |
+
<div className="flex items-center space-x-3">
|
| 139 |
+
{lastReviewed.decision === 'APPROVED' ? (
|
| 140 |
+
<span className="p-2 rounded-lg bg-emerald-500/20 text-emerald-400">
|
| 141 |
+
<CheckCircle2 className="w-5 h-5" />
|
| 142 |
+
</span>
|
| 143 |
+
) : (
|
| 144 |
+
<span className="p-2 rounded-lg bg-rose-500/20 text-rose-400">
|
| 145 |
+
<XCircle className="w-5 h-5" />
|
| 146 |
+
</span>
|
| 147 |
+
)}
|
| 148 |
+
<div>
|
| 149 |
+
<div className="flex items-center space-x-2 text-xs font-bold">
|
| 150 |
+
<span>
|
| 151 |
+
{lastReviewed.decision === 'APPROVED'
|
| 152 |
+
? '✓ Proposal Approved & Merged to Truth Register'
|
| 153 |
+
: '✕ Proposal Rejected & Dismissed'}:
|
| 154 |
+
</span>
|
| 155 |
+
<span className="font-mono text-[11px] opacity-80">PR #{lastReviewed.id.slice(0, 8)}</span>
|
| 156 |
+
<span className="text-[10px] text-slate-400 font-normal">at {lastReviewed.time}</span>
|
| 157 |
+
</div>
|
| 158 |
+
<p className="text-xs text-slate-300 line-clamp-1 mt-0.5 font-sans">
|
| 159 |
+
"{lastReviewed.title}"
|
| 160 |
+
</p>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<div className="flex items-center space-x-3 text-xs">
|
| 165 |
+
{lastReviewed.decision === 'APPROVED' && (
|
| 166 |
+
<Link
|
| 167 |
+
to={`/workspaces/${workspaceId || 'default'}/knowledge`}
|
| 168 |
+
className="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-semibold transition text-xs flex items-center space-x-1"
|
| 169 |
+
>
|
| 170 |
+
<span>Truth Register</span>
|
| 171 |
+
<ChevronRight className="w-3.5 h-3.5" />
|
| 172 |
+
</Link>
|
| 173 |
+
)}
|
| 174 |
+
<button
|
| 175 |
+
onClick={() => setLastReviewed(null)}
|
| 176 |
+
className="text-slate-400 hover:text-white text-lg px-1.5 py-0.5"
|
| 177 |
+
title="Dismiss"
|
| 178 |
+
>
|
| 179 |
+
×
|
| 180 |
+
</button>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
|
| 185 |
{filteredProposals.length === 0 ? (
|
| 186 |
<div className="bg-[#0C101A] border border-slate-800/80 rounded-2xl p-12 text-center shadow-inner space-y-4">
|
| 187 |
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mx-auto">
|
|
|
|
| 344 |
</div>
|
| 345 |
|
| 346 |
{/* Reviewer Comment Box */}
|
| 347 |
+
{activePR.status === 'PENDING' ? (
|
| 348 |
<div className="space-y-3 pt-2 border-t border-slate-800">
|
| 349 |
<label className="block text-xs font-semibold text-slate-300">Review Decision & Notes</label>
|
| 350 |
<textarea
|
|
|
|
| 359 |
<div className="flex items-center justify-end space-x-2 pt-1 text-xs">
|
| 360 |
<button
|
| 361 |
onClick={() => handleReview('ARCHIVED')}
|
| 362 |
+
disabled={!!submittingDecision}
|
| 363 |
+
className="px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white transition font-medium flex items-center space-x-1.5 disabled:opacity-50"
|
| 364 |
>
|
| 365 |
<Archive className="w-3.5 h-3.5" />
|
| 366 |
<span>Archive</span>
|
|
|
|
| 368 |
|
| 369 |
<button
|
| 370 |
onClick={() => handleReview('REJECTED')}
|
| 371 |
+
disabled={!!submittingDecision}
|
| 372 |
+
className="px-3.5 py-2 rounded-xl bg-rose-950/40 hover:bg-rose-900/60 text-rose-300 border border-rose-500/30 transition font-medium flex items-center space-x-1.5 disabled:opacity-50"
|
| 373 |
>
|
| 374 |
+
{submittingDecision === 'REJECTED' ? (
|
| 375 |
+
<div className="w-3.5 h-3.5 border-2 border-rose-400 border-t-transparent rounded-full animate-spin"></div>
|
| 376 |
+
) : (
|
| 377 |
+
<XCircle className="w-3.5 h-3.5 text-rose-400" />
|
| 378 |
+
)}
|
| 379 |
+
<span>{submittingDecision === 'REJECTED' ? 'Rejecting...' : 'Reject PR'}</span>
|
| 380 |
</button>
|
| 381 |
|
| 382 |
<button
|
| 383 |
onClick={() => handleReview('APPROVED')}
|
| 384 |
+
disabled={!!submittingDecision}
|
| 385 |
+
className="px-4 py-2 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-semibold transition flex items-center space-x-1.5 shadow-lg shadow-emerald-600/20 disabled:opacity-50"
|
| 386 |
>
|
| 387 |
+
{submittingDecision === 'APPROVED' ? (
|
| 388 |
+
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
| 389 |
+
) : (
|
| 390 |
+
<CheckCircle2 className="w-3.5 h-3.5" />
|
| 391 |
+
)}
|
| 392 |
+
<span>{submittingDecision === 'APPROVED' ? 'Committing...' : 'Approve & Commit to Master'}</span>
|
| 393 |
</button>
|
| 394 |
</div>
|
| 395 |
</div>
|
| 396 |
+
) : (
|
| 397 |
+
<div className={`p-4 rounded-xl border space-y-2 mt-4 ${
|
| 398 |
+
activePR.status === 'APPROVED'
|
| 399 |
+
? 'bg-emerald-950/30 border-emerald-500/40 text-emerald-200'
|
| 400 |
+
: 'bg-rose-950/30 border-rose-500/40 text-rose-200'
|
| 401 |
+
}`}>
|
| 402 |
+
<div className="flex items-center justify-between">
|
| 403 |
+
<div className="flex items-center space-x-2 font-bold text-xs">
|
| 404 |
+
{activePR.status === 'APPROVED' ? (
|
| 405 |
+
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
| 406 |
+
) : (
|
| 407 |
+
<XCircle className="w-4 h-4 text-rose-400" />
|
| 408 |
+
)}
|
| 409 |
+
<span>Status: {activePR.status}</span>
|
| 410 |
+
{activePR.reviewed_at && (
|
| 411 |
+
<span className="text-[11px] text-slate-400 font-normal">
|
| 412 |
+
({new Date(activePR.reviewed_at).toLocaleTimeString()})
|
| 413 |
+
</span>
|
| 414 |
+
)}
|
| 415 |
+
</div>
|
| 416 |
+
|
| 417 |
+
{activePR.status === 'APPROVED' && (
|
| 418 |
+
<Link
|
| 419 |
+
to={`/workspaces/${workspaceId || 'default'}/knowledge`}
|
| 420 |
+
className="text-xs text-emerald-400 hover:underline flex items-center space-x-1 font-semibold"
|
| 421 |
+
>
|
| 422 |
+
<span>View in Truth Register</span>
|
| 423 |
+
<ChevronRight className="w-3.5 h-3.5" />
|
| 424 |
+
</Link>
|
| 425 |
+
)}
|
| 426 |
+
</div>
|
| 427 |
+
<p className="text-xs text-slate-300">
|
| 428 |
+
{activePR.status === 'APPROVED'
|
| 429 |
+
? 'This knowledge proposal was reviewed and committed directly into the Master Truth Register.'
|
| 430 |
+
: 'This knowledge proposal was rejected and is excluded from active truth retrieval.'}
|
| 431 |
+
</p>
|
| 432 |
+
</div>
|
| 433 |
)}
|
| 434 |
</div>
|
| 435 |
)}
|
studio/src/components/SemanticDiffViewer.jsx
CHANGED
|
@@ -25,6 +25,8 @@ export default function SemanticDiffViewer({ diffData, onReviewProposal, onBatch
|
|
| 25 |
const [comments, setComments] = useState({});
|
| 26 |
const [expandedComments, setExpandedComments] = useState({});
|
| 27 |
const [actionLoading, setActionLoading] = useState({});
|
|
|
|
|
|
|
| 28 |
|
| 29 |
if (loading) {
|
| 30 |
return (
|
|
@@ -35,17 +37,39 @@ export default function SemanticDiffViewer({ diffData, onReviewProposal, onBatch
|
|
| 35 |
);
|
| 36 |
}
|
| 37 |
|
| 38 |
-
const
|
| 39 |
-
const
|
| 40 |
-
const
|
| 41 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
const totalDeltas = additions.length + changes.length + conflicts.length + removals.length;
|
| 44 |
|
| 45 |
const handleAction = async (proposalId, decision) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
setActionLoading((prev) => ({ ...prev, [proposalId]: true }));
|
| 47 |
try {
|
| 48 |
-
await onReviewProposal(proposalId, decision, comments[proposalId] || '');
|
| 49 |
if (decision === 'APPROVED') {
|
| 50 |
confetti({
|
| 51 |
particleCount: 50,
|
|
@@ -54,13 +78,28 @@ export default function SemanticDiffViewer({ diffData, onReviewProposal, onBatch
|
|
| 54 |
colors: ['#10B981', '#14B8A6', '#3B82F6'],
|
| 55 |
});
|
| 56 |
}
|
|
|
|
|
|
|
|
|
|
| 57 |
} finally {
|
| 58 |
setActionLoading((prev) => ({ ...prev, [proposalId]: false }));
|
| 59 |
}
|
| 60 |
};
|
| 61 |
|
| 62 |
const handleBatch = async (decision) => {
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
if (decision === 'APPROVED') {
|
| 65 |
confetti({
|
| 66 |
particleCount: 90,
|
|
@@ -68,6 +107,7 @@ export default function SemanticDiffViewer({ diffData, onReviewProposal, onBatch
|
|
| 68 |
origin: { y: 0.7 },
|
| 69 |
});
|
| 70 |
}
|
|
|
|
| 71 |
};
|
| 72 |
|
| 73 |
return (
|
|
@@ -163,6 +203,43 @@ export default function SemanticDiffViewer({ diffData, onReviewProposal, onBatch
|
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
{totalDeltas === 0 ? (
|
| 167 |
<div className="bg-[#0C101A] border border-slate-800/80 rounded-2xl p-16 text-center shadow-inner">
|
| 168 |
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mx-auto mb-4">
|
|
|
|
| 25 |
const [comments, setComments] = useState({});
|
| 26 |
const [expandedComments, setExpandedComments] = useState({});
|
| 27 |
const [actionLoading, setActionLoading] = useState({});
|
| 28 |
+
const [reviewedLocal, setReviewedLocal] = useState({});
|
| 29 |
+
const [lastActionToast, setLastActionToast] = useState(null);
|
| 30 |
|
| 31 |
if (loading) {
|
| 32 |
return (
|
|
|
|
| 37 |
);
|
| 38 |
}
|
| 39 |
|
| 40 |
+
const rawAdditions = diffData?.additions || [];
|
| 41 |
+
const rawChanges = diffData?.changes || [];
|
| 42 |
+
const rawConflicts = diffData?.conflicts || [];
|
| 43 |
+
const rawRemovals = diffData?.removals || [];
|
| 44 |
+
|
| 45 |
+
// Filter out any delta that was just approved or rejected in local state
|
| 46 |
+
const additions = rawAdditions.filter((a) => !reviewedLocal[a.proposal_id] && !reviewedLocal[a.id]);
|
| 47 |
+
const changes = rawChanges.filter((c) => !reviewedLocal[c.proposal_id] && !reviewedLocal[c.id]);
|
| 48 |
+
const conflicts = rawConflicts.filter((c) => !reviewedLocal[c.proposal_id] && !reviewedLocal[c.id]);
|
| 49 |
+
const removals = rawRemovals.filter((r) => !reviewedLocal[r.proposal_id] && !reviewedLocal[r.id]);
|
| 50 |
|
| 51 |
const totalDeltas = additions.length + changes.length + conflicts.length + removals.length;
|
| 52 |
|
| 53 |
const handleAction = async (proposalId, decision) => {
|
| 54 |
+
const allDeltas = [...rawConflicts, ...rawAdditions, ...rawChanges, ...rawRemovals];
|
| 55 |
+
const found = allDeltas.find((d) => (d.proposal_id === proposalId || d.id === proposalId));
|
| 56 |
+
const title = found?.title || found?.proposed_value || found?.summary || `Delta #${proposalId.slice(0, 8)}`;
|
| 57 |
+
|
| 58 |
+
// Instant local removal & feedback banner
|
| 59 |
+
setReviewedLocal((prev) => ({
|
| 60 |
+
...prev,
|
| 61 |
+
[proposalId]: { decision, title, time: new Date().toLocaleTimeString() }
|
| 62 |
+
}));
|
| 63 |
+
|
| 64 |
+
setLastActionToast({
|
| 65 |
+
proposalId,
|
| 66 |
+
decision,
|
| 67 |
+
title,
|
| 68 |
+
time: new Date().toLocaleTimeString()
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
setActionLoading((prev) => ({ ...prev, [proposalId]: true }));
|
| 72 |
try {
|
|
|
|
| 73 |
if (decision === 'APPROVED') {
|
| 74 |
confetti({
|
| 75 |
particleCount: 50,
|
|
|
|
| 78 |
colors: ['#10B981', '#14B8A6', '#3B82F6'],
|
| 79 |
});
|
| 80 |
}
|
| 81 |
+
await onReviewProposal(proposalId, decision, comments[proposalId] || '');
|
| 82 |
+
} catch (err) {
|
| 83 |
+
console.error('Error applying diff review:', err);
|
| 84 |
} finally {
|
| 85 |
setActionLoading((prev) => ({ ...prev, [proposalId]: false }));
|
| 86 |
}
|
| 87 |
};
|
| 88 |
|
| 89 |
const handleBatch = async (decision) => {
|
| 90 |
+
const allIds = [...rawConflicts, ...rawAdditions, ...rawChanges, ...rawRemovals].map((d) => d.proposal_id || d.id);
|
| 91 |
+
const batchMap = {};
|
| 92 |
+
allIds.forEach((id) => {
|
| 93 |
+
batchMap[id] = { decision, title: 'Batch Knowledge Item' };
|
| 94 |
+
});
|
| 95 |
+
setReviewedLocal((prev) => ({ ...prev, ...batchMap }));
|
| 96 |
+
|
| 97 |
+
setLastActionToast({
|
| 98 |
+
decision,
|
| 99 |
+
title: `Batch of ${allIds.length} semantic fact deltas`,
|
| 100 |
+
time: new Date().toLocaleTimeString()
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
if (decision === 'APPROVED') {
|
| 104 |
confetti({
|
| 105 |
particleCount: 90,
|
|
|
|
| 107 |
origin: { y: 0.7 },
|
| 108 |
});
|
| 109 |
}
|
| 110 |
+
await onBatchReview(decision);
|
| 111 |
};
|
| 112 |
|
| 113 |
return (
|
|
|
|
| 203 |
</div>
|
| 204 |
</div>
|
| 205 |
|
| 206 |
+
{/* Reactive Recent Action Toast Banner */}
|
| 207 |
+
{lastActionToast && (
|
| 208 |
+
<div className={`p-4 rounded-xl border flex items-center justify-between transition-all duration-300 shadow-md ${
|
| 209 |
+
lastActionToast.decision === 'APPROVED'
|
| 210 |
+
? 'bg-emerald-950/40 border-emerald-500/50 text-emerald-200'
|
| 211 |
+
: 'bg-rose-950/40 border-rose-500/50 text-rose-200'
|
| 212 |
+
}`}>
|
| 213 |
+
<div className="flex items-center space-x-3">
|
| 214 |
+
<span className={`p-2 rounded-lg ${lastActionToast.decision === 'APPROVED' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-rose-500/20 text-rose-400'}`}>
|
| 215 |
+
{lastActionToast.decision === 'APPROVED' ? <CheckCircle2 className="w-5 h-5" /> : <XCircle className="w-5 h-5" />}
|
| 216 |
+
</span>
|
| 217 |
+
<div>
|
| 218 |
+
<div className="flex items-center space-x-2 text-xs font-bold">
|
| 219 |
+
<span>
|
| 220 |
+
{lastActionToast.decision === 'APPROVED' ? '✓ Approved & Merged to Master Truth Register' : '✕ Rejected & Dismissed'}:
|
| 221 |
+
</span>
|
| 222 |
+
{lastActionToast.proposalId && (
|
| 223 |
+
<span className="font-mono text-[11px] opacity-80">ID: {lastActionToast.proposalId.slice(0, 8)}</span>
|
| 224 |
+
)}
|
| 225 |
+
<span className="text-[10px] text-slate-400 font-normal">at {lastActionToast.time}</span>
|
| 226 |
+
</div>
|
| 227 |
+
<p className="text-xs text-slate-300 line-clamp-1 mt-0.5 font-sans">
|
| 228 |
+
"{lastActionToast.title}"
|
| 229 |
+
</p>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
<button
|
| 234 |
+
onClick={() => setLastActionToast(null)}
|
| 235 |
+
className="text-slate-400 hover:text-white text-lg px-2"
|
| 236 |
+
title="Dismiss"
|
| 237 |
+
>
|
| 238 |
+
×
|
| 239 |
+
</button>
|
| 240 |
+
</div>
|
| 241 |
+
)}
|
| 242 |
+
|
| 243 |
{totalDeltas === 0 ? (
|
| 244 |
<div className="bg-[#0C101A] border border-slate-800/80 rounded-2xl p-16 text-center shadow-inner">
|
| 245 |
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mx-auto mb-4">
|