molecule-core/canvas/src/components/ConversationTraceModal.tsx
Hongming Wang db48d1d261 fix(canvas): restore text-white on saturated buttons + close zinc gaps
Independent code review of #2555 caught two contrast regressions left
by the bulk perl pass:

1. text-white → text-ink mass-substitution silently broke destructive
   and primary buttons. text-ink resolves to #15181c (warm-paper
   near-black) in light mode — dark text on bg-red-600 / bg-amber-600
   / bg-emerald-600 / bg-blue-600 / bg-accent / bg-accent-strong /
   bg-good / bg-bad fails WCAG contrast and looks broken. Per-line
   pass flips text-ink → text-white only when a saturated bg utility
   is present; tinted-state pills (bg-red-950/50 etc.) keep their
   intentionally-retained text-* literals.

2. Original mapping table was missing bg-zinc-600 (most-used
   hover-state literal for cancel buttons — caused them to JUMP from
   warm cream resting state to dark zinc on hover in light mode) and
   text-zinc-700/800/900 (separator dots and decorative dim text
   invisible on warm-paper light bg). Extended mapping fills these
   gaps with bg-surface-card / text-ink-soft.

Also: drop stale tailwind.config.ts reference from components.json
(file deleted by the v3→v4 migration); switch baseColor zinc →
neutral and enable cssVariables since v4 uses CSS-driven tokens.
Future shadcn-cli invocations would have failed or written malformed
components without this.

27 sites in 27 files affected by #1, ~20 sites in 20 files by #2.
1214/1214 unit tests still pass; build still clean.

Findings courtesy of multi-model review per code-review-and-quality
skill — different blind spots catch different bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:04:20 -07:00

300 lines
13 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { type ActivityEntry } from "@/types/activity";
import { useWorkspaceName } from "@/hooks/useWorkspaceName";
interface Props {
open: boolean;
workspaceId: string;
onClose: () => void;
}
function extractMessageText(body: Record<string, unknown> | null): string {
if (!body) return "";
try {
// Simple task format from MCP server: {task: "..."}
if (body.task && typeof body.task === "string") return body.task;
// Request: params.message.parts[].text
const params = body.params as Record<string, unknown> | undefined;
const message = params?.message as Record<string, unknown> | undefined;
const parts = (message?.parts || []) as Array<Record<string, unknown>>;
const text = parts
.map((p) => (p.text as string) || "")
.filter(Boolean)
.join("\n");
if (text) return text;
// Response: result.parts[].text or result.parts[].root.text
const result = body.result as Record<string, unknown> | undefined;
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
const rText = rParts
.map((p) => {
if (p.text) return p.text as string;
const root = p.root as Record<string, unknown> | undefined;
return (root?.text as string) || "";
})
.filter(Boolean)
.join("\n");
if (rText) return rText;
if (typeof body.result === "string") return body.result;
} catch { /* ignore */ }
return "";
}
export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) {
const [entries, setEntries] = useState<ActivityEntry[]>([]);
const [loading, setLoading] = useState(false);
const nodes = useCanvasStore((s) => s.nodes);
const resolveName = useWorkspaceName();
// Fetch activities from all workspaces (including hidden children) and merge
useEffect(() => {
if (!open) return;
setLoading(true);
const wsIds = nodes.map((n) => n.id);
Promise.all(
wsIds.map((id) =>
api
.get<ActivityEntry[]>(`/workspaces/${id}/activity?limit=200`)
.catch(() => [] as ActivityEntry[])
)
).then((results) => {
// Merge, deduplicate by ID, sort chronologically (oldest first)
const seen = new Set<string>();
const all: ActivityEntry[] = [];
for (const batch of results) {
for (const entry of batch) {
if (!seen.has(entry.id)) {
seen.add(entry.id);
all.push(entry);
}
}
}
all.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
setEntries(all);
setLoading(false);
});
}, [open, nodes]);
const isA2A = (e: ActivityEntry) =>
e.activity_type === "a2a_receive" || e.activity_type === "a2a_send";
return (
<Dialog.Root open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<Dialog.Portal>
{/* Overlay replaces the old manual backdrop div */}
<Dialog.Overlay className="fixed inset-0 z-[59] bg-black/70 backdrop-blur-sm" />
{/* Content wraps the entire centred modal panel */}
<Dialog.Content
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
aria-label="Conversation trace"
>
{/* Modal panel */}
<div className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-line">
<div>
<Dialog.Title className="text-sm font-semibold text-ink">
Conversation Trace
</Dialog.Title>
<p className="text-[10px] text-ink-soft mt-0.5">
{entries.length} events across all workspaces
</p>
</div>
<Dialog.Close asChild>
<button
type="button"
aria-label="Close conversation trace"
className="text-ink-soft hover:text-ink-mid text-lg px-2"
>
</button>
</Dialog.Close>
</div>
{/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="text-xs text-ink-soft text-center py-8">
Loading trace from all workspaces...
</div>
)}
{!loading && entries.length === 0 && (
<div className="text-xs text-ink-soft text-center py-8">
No activity found
</div>
)}
<div className="space-y-1">
{entries.map((entry) => {
const time = new Date(entry.created_at).toLocaleTimeString();
const wsName = resolveName(entry.workspace_id);
const sourceName = resolveName(entry.source_id);
const targetName = resolveName(entry.target_id);
const requestText = extractMessageText(entry.request_body);
const responseText = extractMessageText(entry.response_body);
const isError = entry.status === "error";
const isSend = entry.activity_type === "a2a_send";
const isReceive = entry.activity_type === "a2a_receive";
return (
<div key={entry.id} className="group">
{/* Event header */}
<div className="flex items-start gap-3">
{/* Timeline dot + line */}
<div className="flex flex-col items-center pt-1.5">
<div
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
isError
? "bg-red-500"
: isSend
? "bg-cyan-500"
: isReceive
? "bg-accent"
: "bg-surface-card"
}`}
/>
<div className="w-px flex-1 bg-surface-card min-h-[8px]" />
</div>
{/* Content */}
<div className="flex-1 pb-3 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[9px] text-ink-mid font-mono">
{time}
</span>
<span
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
isError
? "bg-red-950/50 text-bad"
: isSend
? "bg-cyan-950/50 text-cyan-400"
: isReceive
? "bg-blue-950/50 text-accent"
: "bg-surface-card text-ink-mid"
}`}
>
{isSend
? "SEND"
: isReceive
? "RECEIVE"
: entry.activity_type.toUpperCase()}
</span>
{entry.duration_ms != null && entry.duration_ms > 0 && (
<span className="text-[9px] text-ink-mid">
{entry.duration_ms > 1000
? `${Math.round(entry.duration_ms / 1000)}s`
: `${entry.duration_ms}ms`}
</span>
)}
</div>
{/* Flow */}
{isA2A(entry) && (
<div className="text-[11px] mt-1">
{isSend ? (
<span>
<span className="text-cyan-400 font-medium">
{sourceName || wsName}
</span>
<span className="text-ink-mid"> </span>
<span className="text-accent font-medium">
{targetName}
</span>
</span>
) : (
<span>
<span className="text-accent font-medium">
{targetName || wsName}
</span>
{sourceName && (
<>
<span className="text-ink-mid">
{" "} {" "}
</span>
<span className="text-cyan-400 font-medium">
{sourceName}
</span>
</>
)}
</span>
)}
</div>
)}
{/* Summary */}
{entry.summary && !isA2A(entry) && (
<div className="text-[10px] text-ink-mid mt-1">
<span className="text-ink-mid font-medium">{wsName}:</span>{" "}
{entry.summary}
</div>
)}
{/* Error */}
{isError && entry.error_detail && (
<div className="text-[10px] text-bad/80 mt-1 truncate">
{entry.error_detail.slice(0, 200)}
</div>
)}
{/* Message content — show request and/or response */}
{requestText && (
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-ink-soft uppercase mb-1">
{isSend ? "Task" : "Request"}
</div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
{requestText.slice(0, 2000)}
{requestText.length > 2000 && (
<span className="text-ink-mid"> ...({requestText.length} chars)</span>
)}
</div>
</div>
)}
{responseText && (
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-good/60 uppercase mb-1">Response</div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
{responseText.slice(0, 2000)}
{responseText.length > 2000 && (
<span className="text-ink-mid"> ...({responseText.length} chars)</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-line bg-surface/50 flex justify-end">
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
>
Close
</button>
</Dialog.Close>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}