Files
penguin-harness/packages/web/src/features/traces/trace-event-row.tsx
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Trace event row (shared): timestamp + type icon + type badge + one-line
* summary; expanding **renders** the content (text/thinking and session_meta's
* system prompt go through Markdown, tool arguments/output go through a code
* block, token_usage goes through a bucket table, session_meta goes through a
* field table + tool definitions) instead of dumping raw JSON;
* the stop reason (stop_reason / status) is shown in the bottom-right of the
* expanded area.
* Linked highlighting with the timeline — a matched row only gets a
* background color, never an outline; only one row lights up at a time (each
* row has its own unique rowKey, so adjacent messages at the same timestamp
* aren't highlighted together).
*/
import { Fragment, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { S } from "../../lib/strings";
import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
import { formatTime, humanizeTokens } from "../../lib/format";
import { Badge, stopReasonTone } from "../../components/ui/badge";
import type { BadgeTone } from "../../components/ui/badge";
import { ZoomableImage } from "../../components/ui/image-zoom";
export function typeTone(type: string): BadgeTone {
if (type === "session_meta") return "brand";
if (type === "model_msg") return "gray";
return "amber";
}
/** Icon for each event type (24×24 line path). */
const TYPE_ICON: Record<string, string> = {
text: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5",
thinking: "M9 18h6M10 21h4M12 3a6 6 0 0 0-3 11v2h6v-2a6 6 0 0 0-3-11z",
image_url: "M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6",
inline_data: "M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6",
tool_call: "M14.7 6.3a4 4 0 0 0-5 5L4 17v3h3l5.7-5.7a4 4 0 0 0 5-5l-2.5 2.5-2-2 2.5-2.5z",
tool_call_output: "M4 6l4 4-4 4M12 18h8",
token_usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
request_begin: "M5 12h14M13 6l6 6-6 6",
request_end: "M19 12H5M11 6l-6 6 6 6",
approval_decision: "M9 12l2 2 4-4M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z",
compaction_begin: "M8 3H4v4M16 3h4v4M8 21H4v-4M16 21h4v-4M9 12h6",
compaction_end: "M8 3H4v4M16 3h4v4M8 21H4v-4M16 21h4v-4M9 12h6",
abort: "M6 6h12v12H6z",
subagent: "M12 3v6m0 0l-5 4v8m5-12l5 4v8M4 21h16",
};
const DEFAULT_ICON = "M12 8v5m0 3h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z";
function TypeIcon({ type }: { type: string }) {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 text-gray-400 dark:text-gray-500"
aria-hidden
>
<path d={TYPE_ICON[type] ?? DEFAULT_ICON} />
</svg>
);
}
/** One-line summary: pull key info per payload type (truncated to one line). */
export function summarizeEvent(msg: OmniMessage): string {
const p = msg.payload as Record<string, unknown> & { type?: string };
switch (p.type) {
case undefined:
return "";
case "text":
return `${String(p["role"])}: ${String(p["text"] ?? "").slice(0, 120)}`;
case "thinking":
return String(p["thinking"] ?? "").slice(0, 120);
case "image_url":
return "[image]";
case "inline_data":
return `[image] ${String(p["mime_type"] ?? "")}`;
case "tool_call":
return `${String(p["name"])} ${String(p["arguments"] ?? "").slice(0, 100)}`;
case "tool_call_output": {
const text = String(p["output"] ?? "").slice(0, 120);
// Tool output carrying an image: prefix the summary with [image] to flag it.
return Array.isArray(p["images"]) && p["images"].length > 0 ? `[image] ${text}` : text;
}
case "token_usage": {
const req = p["request"] as { total?: number } | undefined;
const ses = p["session"] as { total?: number } | undefined;
return `request.total=${req?.total ?? 0} session.total=${ses?.total ?? 0}`;
}
case "request_end":
return `status=${String(p["status"])}`;
case "approval_decision":
return `${String(p["decision"])} · ${String(p["tool_call_id"])}`;
case "compaction_begin":
case "compaction_end":
return `${String(p["mode"])} (${String(p["reason"])})${p["status"] ? ` · ${String(p["status"])}` : ""}`;
case "abort":
return p["reason"] != null ? String(p["reason"]) : "";
case "subagent":
return String(p["session_id"] ?? "");
default:
if (msg.type === "session_meta") {
return `${String(p["session_id"] ?? "")} · ${String(p["model_id"] ?? "")}`;
}
return "";
}
}
/** Stop reason (a model message's stop_reason / request_end's status); undefined if absent. */
function stopReasonOf(msg: OmniMessage): string | undefined {
const p = msg.payload as { stop_reason?: string; status?: string };
return p.stop_reason ?? p.status;
}
const codeBlock =
"max-h-72 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-100 px-2.5 py-2 text-xs dark:bg-gray-800/70";
/** token_usage bucket table (a single event only reports exact numbers; the donut ring only appears in the top-right of the Task card, so it isn't redrawn here). */
function UsageTable({ p }: { p: Record<string, unknown> }) {
const req = (p.request ?? {}) as Record<string, number>;
const ses = (p.session ?? {}) as Record<string, number>;
const row = (label: string, r: Record<string, number>) => (
<tr>
<td className="pr-3 text-gray-400">{label}</td>
<td className="pr-3 text-right font-mono">{humanizeTokens(r.cache_read ?? 0)}</td>
<td className="pr-3 text-right font-mono">{humanizeTokens(r.cache_write ?? 0)}</td>
<td className="pr-3 text-right font-mono">{humanizeTokens(r.output ?? 0)}</td>
<td className="text-right font-mono">{humanizeTokens(r.total ?? 0)}</td>
</tr>
);
return (
<table className="text-xs">
<thead>
<tr className="text-gray-400">
<th />
<th className="pr-3 text-right font-normal">cacheRead</th>
<th className="pr-3 text-right font-normal">cacheWrite</th>
<th className="pr-3 text-right font-normal">output</th>
<th className="text-right font-normal">total</th>
</tr>
</thead>
<tbody>
{row("request", req)}
{row("session", ses)}
</tbody>
</table>
);
}
const summaryClass =
"cursor-pointer text-xs font-medium text-gray-500 marker:text-gray-400 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200";
/** session_meta: a field table + system prompt (Markdown) + tool definition list. */
function SessionMetaBody({ p }: { p: Record<string, unknown> }) {
const rows: Array<[string, string]> = [
["session_id", String(p.session_id ?? "")],
["model_id", String(p.model_id ?? "")],
["context_window", String(p.model_context_window ?? "")],
["thinking_level", String(p.thinking_level ?? "")],
["agent_state", String(p.agent_state ?? "")],
["workspace", String(p.workspace ?? "")],
];
const prompt = String(p.system_prompt ?? "");
const tools = Array.isArray(p.tools)
? (p.tools as Array<{ name?: string; description?: string }>)
: [];
return (
<div className="space-y-2.5">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
{rows.map(([k, v]) => (
<Fragment key={k}>
<dt className="text-gray-400">{k}</dt>
<dd className="min-w-0 break-all font-mono text-gray-700 dark:text-gray-300">
{v || "—"}
</dd>
</Fragment>
))}
</dl>
{prompt.trim() && (
<details>
<summary className={summaryClass}>{S.traces.systemPrompt}</summary>
<div className="md-body mt-1.5 max-h-96 overflow-auto rounded bg-gray-100 px-2.5 py-2 text-sm leading-relaxed text-gray-700 dark:bg-gray-800/70 dark:text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{prompt}</ReactMarkdown>
</div>
</details>
)}
{tools.length > 0 && (
<details>
<summary className={summaryClass}>{S.traces.toolDefs(tools.length)}</summary>
<ul className="mt-1.5 space-y-1">
{tools.map((t, i) => (
<li key={i} className="text-xs">
<span className="font-mono font-semibold text-gray-700 dark:text-gray-300">
{t.name ?? "—"}
</span>
{t.description && (
<span className="ml-2 text-gray-500 dark:text-gray-400">{t.description}</span>
)}
</li>
))}
</ul>
</details>
)}
</div>
);
}
/** Expanded-area content: rendered per type (no raw JSON dump). */
function EventBody({ msg }: { msg: OmniMessage }) {
const p = msg.payload as Record<string, unknown> & { type?: string };
if (msg.type === "session_meta") return <SessionMetaBody p={p} />;
switch (p.type) {
case "text":
case "thinking": {
const md = String(p.text ?? p.thinking ?? "");
if (!md.trim()) return <p className="text-xs text-gray-400">—</p>;
return (
<div className="md-body text-sm leading-relaxed text-gray-700 dark:text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{md}</ReactMarkdown>
</div>
);
}
case "image_url":
return (
<ZoomableImage
src={String(p.image_url ?? "")}
alt="trace"
className="max-h-48 max-w-full rounded-md"
/>
);
case "inline_data":
// base64 bytes + mime → render directly as a data URL.
return (
<ZoomableImage
src={`data:${String(p.mime_type ?? "")};base64,${String(p.data ?? "")}`}
alt="trace"
className="max-h-48 max-w-full rounded-md"
/>
);
case "tool_call":
return (
<div className="space-y-1.5">
<p className="font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
{String(p.name ?? "")}
</p>
<pre className={codeBlock}>{String(p.arguments ?? "")}</pre>
</div>
);
case "tool_call_output": {
// Tool output carrying images (e.g. read_image): render images below the text code block.
const images = Array.isArray(p.images) ? (p.images as string[]) : [];
if (images.length === 0) return <pre className={codeBlock}>{String(p.output ?? "")}</pre>;
return (
<div className="space-y-1.5">
<pre className={codeBlock}>{String(p.output ?? "")}</pre>
<div className="flex flex-wrap gap-2">
{images.map((src, i) => (
<ZoomableImage
key={i}
src={src}
alt="trace"
className="max-h-48 max-w-full rounded-md"
/>
))}
</div>
</div>
);
}
case "token_usage":
return <UsageTable p={p} />;
default:
// Short events like compaction / abort / request_*: key-value pairs are enough (session_meta is already rendered separately above).
return <pre className={codeBlock}>{JSON.stringify(p, null, 2)}</pre>;
}
}
export function EventRow({
msg,
rowKey,
matched,
onHighlight,
}: {
msg: OmniMessage;
/** This row's unique identifier (the scroll target for timeline jumps, and also the basis for "only one row lights up"). */
rowKey: string;
matched: boolean;
onHighlight?: (h: { ts: string; rowKey: string } | null) => void;
}) {
const [open, setOpen] = useState(false);
const payloadType = (msg.payload as { type?: string }).type ?? msg.type;
const stopReason = stopReasonOf(msg);
return (
<li data-trace-row={rowKey}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
onMouseEnter={() => onHighlight?.({ ts: msg.timestamp, rowKey })}
onMouseLeave={() => onHighlight?.(null)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors duration-150 ${
matched
? "bg-amber-50 dark:bg-amber-950/40"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`}
>
<span className="shrink-0 font-mono text-[11px] text-gray-400">
{formatTime(msg.timestamp)}
</span>
<TypeIcon type={payloadType} />
<Badge tone={typeTone(msg.type)}>{payloadType}</Badge>
{msg.origin && msg.origin.length > 0 && <Badge tone="brand">origin</Badge>}
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-500 dark:text-gray-400">
{summarizeEvent(msg)}
</span>
</button>
{open && (
<div className="border-t border-gray-100 bg-gray-50/60 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
<EventBody msg={msg} />
{/* Stop reason: bottom-right */}
{stopReason && (
<div className="mt-1.5 flex justify-end">
<Badge tone={stopReasonTone(stopReason)}>{stopReason}</Badge>
</div>
)}
</div>
)}
</li>
);
}