Skip to content

Commit 63986b6

Browse files
committed
Improve event log tool use rendering
1 parent 4608471 commit 63986b6

3 files changed

Lines changed: 359 additions & 10 deletions

File tree

web/src/components/EventLogModal.tsx

Lines changed: 234 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,7 @@ function ChatView({ turns }: { turns: Turn[] }) {
887887
const rawExpanded = rawText != null && expandedRaw.has(index);
888888
const hasActions = isTool || rawText != null;
889889
const bubbleClass = isTool ? "chat-bubble-tool" : chatBubbleClass(turn.role);
890+
const parts = rawExpanded || collapsed ? null : visualContentParts(turn);
890891
return (
891892
<div
892893
key={index}
@@ -925,17 +926,16 @@ function ChatView({ turns }: { turns: Turn[] }) {
925926
<pre className="chat-raw-json">{rawText}</pre>
926927
) : collapsed ? (
927928
<div className="chat-preview">{chatPreview(turn)}</div>
929+
) : parts ? (
930+
<>
931+
<StructuredContentParts parts={parts} fallbackText={turnText(turn)} />
932+
{turn.attachments?.length ? (
933+
<div className="chat-attachments">attachments: {turn.attachments.join("; ")}</div>
934+
) : null}
935+
</>
928936
) : (
929937
<>
930-
<div className="chat-body prose-md">
931-
<ReactMarkdown
932-
remarkPlugins={[remarkGfm]}
933-
urlTransform={markdownUrlTransform}
934-
components={{ strong: MarkdownStrong }}
935-
>
936-
{turnText(turn)}
937-
</ReactMarkdown>
938-
</div>
938+
<MarkdownContent text={turnText(turn)} className="chat-body prose-md" />
939939
{turn.attachments?.length ? (
940940
<div className="chat-attachments">attachments: {turn.attachments.join("; ")}</div>
941941
) : null}
@@ -949,6 +949,231 @@ function ChatView({ turns }: { turns: Turn[] }) {
949949
);
950950
}
951951

952+
function MarkdownContent({ text, className }: { text: string; className: string }) {
953+
return (
954+
<div className={className}>
955+
<ReactMarkdown
956+
remarkPlugins={[remarkGfm]}
957+
urlTransform={markdownUrlTransform}
958+
components={{ strong: MarkdownStrong }}
959+
>
960+
{text}
961+
</ReactMarkdown>
962+
</div>
963+
);
964+
}
965+
966+
function StructuredContentParts({
967+
parts,
968+
fallbackText,
969+
}: {
970+
parts: Record<string, unknown>[];
971+
fallbackText: string;
972+
}) {
973+
return (
974+
<div className="chat-parts">
975+
{parts.map((part, index) => (
976+
<StructuredContentPart key={index} part={part} index={index} />
977+
))}
978+
{parts.length === 0 && <MarkdownContent text={fallbackText} className="chat-body prose-md" />}
979+
</div>
980+
);
981+
}
982+
983+
function StructuredContentPart({ part, index }: { part: Record<string, unknown>; index: number }) {
984+
const type = stringRecordValue(part.type);
985+
if (type === "text" || type === "input_text" || type === "output_text") {
986+
const text = stringRecordValue(part.text);
987+
return text ? <MarkdownContent text={text} className="chat-part-text prose-md" /> : null;
988+
}
989+
if (type === "thinking" || type === "reasoning") {
990+
return <ThinkingPart part={part} type={type} />;
991+
}
992+
if (type === "redacted_thinking") {
993+
return (
994+
<div className="chat-hidden-part">
995+
<span className="part-type-badge">redacted_thinking</span>
996+
<span>Encrypted reasoning block</span>
997+
</div>
998+
);
999+
}
1000+
if (type === "tool_use") {
1001+
return <ToolUsePart part={part} index={index} />;
1002+
}
1003+
if (type === "tool_result") {
1004+
return <ToolResultPart part={part} />;
1005+
}
1006+
return <GenericPart part={part} type={type || `part ${index + 1}`} />;
1007+
}
1008+
1009+
function ThinkingPart({ part, type }: { part: Record<string, unknown>; type: string }) {
1010+
const text = stringRecordValue(part.thinking) || stringRecordValue(part.text);
1011+
return (
1012+
<div className="thinking-part">
1013+
<div className="part-header">
1014+
<span className="part-type-badge">{type}</span>
1015+
{!text && <span className="part-muted">encrypted or redacted</span>}
1016+
</div>
1017+
{text ? <div className="thinking-text">{text}</div> : null}
1018+
</div>
1019+
);
1020+
}
1021+
1022+
function ToolUsePart({ part, index }: { part: Record<string, unknown>; index: number }) {
1023+
const name = stringRecordValue(part.name) || `tool_${index + 1}`;
1024+
const id = stringRecordValue(part.id) || stringRecordValue(part.tool_use_id) || stringRecordValue(part.call_id);
1025+
const input = part.input ?? {};
1026+
const questions = askUserQuestions(input);
1027+
return (
1028+
<div className="tool-use-card">
1029+
<div className="tool-use-header">
1030+
<div className="tool-use-title">
1031+
<span className="part-type-badge">tool_use</span>
1032+
<span className="tool-name">{name}</span>
1033+
</div>
1034+
{id && <span className="tool-id">{id}</span>}
1035+
</div>
1036+
{questions ? (
1037+
<AskUserQuestionView questions={questions} />
1038+
) : (
1039+
<JsonValueBlock label="Input" value={input} />
1040+
)}
1041+
</div>
1042+
);
1043+
}
1044+
1045+
function ToolResultPart({ part }: { part: Record<string, unknown> }) {
1046+
const id = stringRecordValue(part.tool_use_id) || stringRecordValue(part.id) || stringRecordValue(part.call_id);
1047+
return (
1048+
<div className="tool-result-card">
1049+
<div className="tool-use-header">
1050+
<div className="tool-use-title">
1051+
<span className="part-type-badge">tool_result</span>
1052+
{id && <span className="tool-id">{id}</span>}
1053+
</div>
1054+
</div>
1055+
<ToolResultContent value={part.content} />
1056+
</div>
1057+
);
1058+
}
1059+
1060+
function ToolResultContent({ value }: { value: unknown }) {
1061+
if (typeof value === "string") {
1062+
return <pre className="tool-json-block">{value || "—"}</pre>;
1063+
}
1064+
if (Array.isArray(value)) {
1065+
const objectParts = value.filter(isRecord);
1066+
if (objectParts.length === value.length && objectParts.length > 0) {
1067+
return <StructuredContentParts parts={objectParts} fallbackText="" />;
1068+
}
1069+
}
1070+
return <JsonValueBlock label="Content" value={value ?? null} />;
1071+
}
1072+
1073+
interface VisualQuestion {
1074+
header: string;
1075+
question: string;
1076+
multiSelect: boolean;
1077+
options: VisualQuestionOption[];
1078+
}
1079+
1080+
interface VisualQuestionOption {
1081+
label: string;
1082+
description: string;
1083+
}
1084+
1085+
function AskUserQuestionView({ questions }: { questions: VisualQuestion[] }) {
1086+
return (
1087+
<div className="ask-user-questions">
1088+
{questions.map((q, index) => (
1089+
<div key={`${q.header}-${index}`} className="ask-question-card">
1090+
<div className="ask-question-meta">
1091+
{q.header && <span>{q.header}</span>}
1092+
<span>{q.multiSelect ? "multi select" : "single select"}</span>
1093+
</div>
1094+
<div className="ask-question-text">{q.question || "Question"}</div>
1095+
{q.options.length > 0 && (
1096+
<div className="ask-options">
1097+
{q.options.map((option, optionIndex) => (
1098+
<div key={`${option.label}-${optionIndex}`} className="ask-option">
1099+
<div className="ask-option-label">{option.label || `Option ${optionIndex + 1}`}</div>
1100+
{option.description && (
1101+
<div className="ask-option-description">{option.description}</div>
1102+
)}
1103+
</div>
1104+
))}
1105+
</div>
1106+
)}
1107+
</div>
1108+
))}
1109+
</div>
1110+
);
1111+
}
1112+
1113+
function GenericPart({ part, type }: { part: Record<string, unknown>; type: string }) {
1114+
return (
1115+
<div className="generic-part">
1116+
<div className="part-header">
1117+
<span className="part-type-badge">{type}</span>
1118+
</div>
1119+
<JsonValueBlock label="Payload" value={part} />
1120+
</div>
1121+
);
1122+
}
1123+
1124+
function JsonValueBlock({ label, value }: { label: string; value: unknown }) {
1125+
return (
1126+
<div className="tool-json-wrap">
1127+
<div className="tool-json-label">{label}</div>
1128+
<pre className="tool-json-block">{rawValueText(value)}</pre>
1129+
</div>
1130+
);
1131+
}
1132+
1133+
function visualContentParts(turn: Turn): Record<string, unknown>[] | null {
1134+
const raw = isRecord(turn.raw) ? turn.raw : null;
1135+
const content = raw?.content;
1136+
if (!Array.isArray(content)) return null;
1137+
const parts = content.filter(isRecord);
1138+
if (parts.length === 0) return null;
1139+
return parts.some((part) => !isPlainTextPart(part)) ? parts : null;
1140+
}
1141+
1142+
function isPlainTextPart(part: Record<string, unknown>): boolean {
1143+
const type = stringRecordValue(part.type);
1144+
return (type === "text" || type === "input_text" || type === "output_text") && typeof part.text === "string";
1145+
}
1146+
1147+
function askUserQuestions(input: unknown): VisualQuestion[] | null {
1148+
const obj = isRecord(input) ? input : null;
1149+
const rawQuestions = obj?.questions;
1150+
if (!Array.isArray(rawQuestions)) return null;
1151+
const questions = rawQuestions.filter(isRecord).map((q): VisualQuestion => {
1152+
const rawOptions = q.options;
1153+
const options = Array.isArray(rawOptions)
1154+
? rawOptions.filter(isRecord).map((option) => ({
1155+
label: stringRecordValue(option.label),
1156+
description: stringRecordValue(option.description),
1157+
}))
1158+
: [];
1159+
return {
1160+
header: stringRecordValue(q.header),
1161+
question: stringRecordValue(q.question),
1162+
multiSelect: q.multiSelect === true || q.multi_select === true,
1163+
options,
1164+
};
1165+
});
1166+
return questions.length > 0 ? questions : null;
1167+
}
1168+
1169+
function isRecord(value: unknown): value is Record<string, unknown> {
1170+
return !!value && typeof value === "object" && !Array.isArray(value);
1171+
}
1172+
1173+
function stringRecordValue(value: unknown): string {
1174+
return typeof value === "string" ? value : "";
1175+
}
1176+
9521177
function turnRawText(turn: Turn): string | null {
9531178
if (!Object.prototype.hasOwnProperty.call(turn, "raw")) return null;
9541179
return rawValueText(turn.raw);

web/src/lib/protocol.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ export interface StreamExtraction {
609609
hiddenType?: string;
610610
errors: string[];
611611
raw?: unknown;
612+
rawContent?: unknown[];
612613
}
613614

614615
interface OpenAIImageGeneration {
@@ -919,21 +920,34 @@ export function extractResponseStream(text: string): StreamExtraction {
919920

920921
// Flush Anthropic blocks in the order they were declared so tool_use calls
921922
// appear inline alongside the assistant's prose.
923+
const anthropicContent: Record<string, unknown>[] = [];
922924
for (const idx of blockOrder) {
923925
const b = blocks.get(idx);
924926
if (!b) continue;
925927
if (b.type === "text") {
926928
out.content += b.text;
929+
if (b.text) anthropicContent.push({ type: "text", text: b.text });
927930
} else if (b.type === "thinking") {
928931
if (b.text) out.thinking += b.text;
929932
else if (b.encrypted) markHidden(out, b.hiddenType || b.type);
933+
anthropicContent.push({
934+
type: "thinking",
935+
...(b.text ? { thinking: b.text } : {}),
936+
...(b.encrypted ? { encrypted: true } : {}),
937+
});
930938
} else if (b.type === "redacted_thinking") {
931939
markHidden(out, b.hiddenType || b.type);
940+
anthropicContent.push({ type: "redacted_thinking" });
932941
} else if (b.type === "tool_use") {
933942
const json = formatPartialJSON(b.partialJSON);
934943
const name = b.name || "tool";
935944
const sep = out.content ? "\n\n" : "";
936945
out.content += sep + toolUseMarkdown(name, undefined, json, "json");
946+
anthropicContent.push({
947+
type: "tool_use",
948+
name,
949+
input: parsePartialJSONValue(b.partialJSON),
950+
});
937951
} else {
938952
const raw = {
939953
...b.raw,
@@ -943,8 +957,10 @@ export function extractResponseStream(text: string): StreamExtraction {
943957
...(b.partialJSON ? { partial_json: b.partialJSON } : {}),
944958
};
945959
appendResponseMarkdown(out, typedItemMarkdown(raw, b.type));
960+
anthropicContent.push(raw);
946961
}
947962
}
963+
if (anthropicContent.length > 0) out.rawContent = anthropicContent;
948964

949965
// Flush OpenAI Responses output items in arrival order.
950966
const responseItems: Array<{ order: number; markdown: string }> = [];
@@ -1019,7 +1035,7 @@ function synthesizedAssistantResponse(extraction: StreamExtraction): Record<stri
10191035
const thinking = extraction.thinking.trim();
10201036
return {
10211037
role: "assistant",
1022-
content,
1038+
content: extraction.rawContent ?? content,
10231039
output: synthesizedResponseOutput(extraction),
10241040
...(thinking ? { thinking } : {}),
10251041
...(extraction.hiddenType ? { hidden_type: extraction.hiddenType } : {}),
@@ -1041,6 +1057,16 @@ function formatPartialJSON(raw: string): string {
10411057
}
10421058
}
10431059

1060+
function parsePartialJSONValue(raw: string): unknown {
1061+
const t = raw.trim();
1062+
if (!t) return {};
1063+
try {
1064+
return JSON.parse(t);
1065+
} catch {
1066+
return t;
1067+
}
1068+
}
1069+
10441070
// extractResponseJSON handles non-streaming JSON responses (e.g. the upstream
10451071
// 4xx body in an API RESPONSE attempt). Returns null when nothing useful is
10461072
// found.

0 commit comments

Comments
 (0)