blue 1ee93cd186
feat(frontend): add expandable generic tool details in debug mode (#5309)
* feat(frontend): add expandable generic tool details in debug mode

* fix: address tool call details review feedback

* fix: preserve existing ellipsis keys in tool previews

* fix(frontend): preserve tool preview property names

* fix(frontend): keep bounded tool previews structurally complete

* fix(frontend): avoid repeated array preview truncation markers

* fix(frontend): preserve precision in tool result previews

* fix(frontend): coalesce generated array tail markers
2026-09-12 09:16:37 +08:00

117 lines
3.4 KiB
TypeScript

"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { useEffect, useId, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { formatToolDetail } from "@/core/messages/tool-detail-preview";
export function ToolCallDetails({
name,
callId,
args,
resultMessage,
}: {
name: string;
callId?: string;
args: Record<string, unknown>;
resultMessage?: Extract<Message, { type: "tool" }>;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const panelId = useId();
return (
<div className="min-w-0">
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`${t.toolCalls.details}: ${name}${callId ? ` (${callId})` : ""}`}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen(!open)}
>
{t.toolCalls.details}
</Button>
{open && (
<div id={panelId} className="space-y-3 rounded-md border p-3">
<Payload label={t.toolCalls.toolName} value={name} />
<Payload label={t.toolCalls.callId} value={callId ?? "—"} />
<Payload label={t.toolCalls.input} value={args} />
{resultMessage ? (
<Payload
label={
resultMessage.status === "error"
? t.toolCalls.error
: t.toolCalls.result
}
value={resultMessage.content}
/>
) : (
<p>{t.toolCalls.noResult}</p>
)}
</div>
)}
</div>
);
}
function Payload({ label, value }: { label: string; value: unknown }) {
const { t } = useI18n();
const preview = useMemo(() => formatToolDetail(value), [value]);
const [copyStatus, setCopyStatus] = useState<{
text: string;
message: string;
}>();
const visibleText = preview.text === "" ? '""' : preview.text;
useEffect(() => {
if (!copyStatus) return;
const timer = setTimeout(() => setCopyStatus(undefined), 2000);
return () => clearTimeout(timer);
}, [copyStatus]);
async function copy() {
try {
if (!(await writeTextToClipboard(visibleText))) {
throw new Error("Clipboard write failed");
}
setCopyStatus({
text: visibleText,
message: t.clipboard.copiedToClipboard,
});
} catch {
setCopyStatus({
text: visibleText,
message: t.clipboard.failedToCopyToClipboard,
});
}
}
return (
<section aria-label={label} className="min-w-0 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{label}</span>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={`${t.clipboard.copyToClipboard}: ${label}`}
onClick={copy}
>
{t.clipboard.copyToClipboard}
</Button>
</div>
<pre className="bg-muted max-h-64 overflow-auto rounded p-2 text-xs break-all whitespace-pre-wrap">
{visibleText}
</pre>
{preview.text === "" && (
<p className="text-xs">{t.toolCalls.emptyResult}</p>
)}
{preview.truncated && <p className="text-xs">{t.toolCalls.truncated}</p>}
<span role="status" className="text-xs">
{copyStatus?.text === visibleText ? copyStatus.message : ""}
</span>
</section>
);
}