This commit is contained in:
Ishaan Jaffer 2026-01-30 17:34:54 -08:00
parent e1b1263da1
commit 316fff15db
13 changed files with 723 additions and 124 deletions

View file

@ -0,0 +1,74 @@
/**
* CollapsibleMessage - Collapsible message with arrow and char count
* Used for system messages
*/
import { useState } from 'react';
import { Typography } from 'antd';
import { DownOutlined, RightOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface CollapsibleMessageProps {
label: string;
content?: string;
defaultExpanded?: boolean;
}
export function CollapsibleMessage({
label,
content,
defaultExpanded = false
}: CollapsibleMessageProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const charCount = content?.length || 0;
if (!content || charCount === 0) {
return null;
}
return (
<div style={{ marginBottom: 12 }}>
{/* Clickable Header */}
<div
onClick={() => setIsExpanded(!isExpanded)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
cursor: 'pointer',
marginBottom: isExpanded ? 6 : 0,
}}
>
{isExpanded ? (
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
) : (
<RightOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
)}
<Text type="secondary" style={{ fontSize: 11 }}>
{label}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
({charCount.toLocaleString()} chars)
</Text>
</div>
{/* Content */}
{isExpanded && (
<div
style={{
paddingLeft: 16,
fontSize: 13,
lineHeight: 1.6,
color: '#262626',
borderLeft: '1px solid #f0f0f0',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{content}
</div>
)}
</div>
);
}

View file

@ -1,73 +1,58 @@
/**
* HistorySection - Collapsible section for displaying message history
* Shows a summary when collapsed, full messages when expanded
* HistoryDivider - Collapsible divider for message history
* Dashed line with expandable content
*/
import { useState } from 'react';
import { Typography } from 'antd';
import { UpOutlined, DownOutlined } from '@ant-design/icons';
import { ParsedMessage } from './prettyMessagesTypes';
import { MessageCard } from './MessageCard';
import { ROLE_STYLES } from './prettyMessagesUtils';
import { MessageBlock } from './MessageBlock';
const { Text } = Typography;
interface HistorySectionProps {
interface HistoryDividerProps {
messages: ParsedMessage[];
}
export function HistorySection({ messages }: HistorySectionProps) {
export function HistoryDivider({ messages }: HistoryDividerProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (messages.length === 0) return null;
// Build summary strip showing message flow
const summary = messages
.map((m) => {
const style = ROLE_STYLES[m.role] || ROLE_STYLES.user;
return style.label;
})
.join(' → ');
return (
<div style={{ margin: '16px 0' }}>
{/* Collapsed: Dashed line with label */}
<div style={{ margin: '12px 0' }}>
{/* Dashed Divider with Label */}
<div
onClick={() => setIsExpanded(!isExpanded)}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
gap: 12,
gap: 8,
}}
>
<div style={{ flex: 1, borderTop: '1px dashed #d9d9d9' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
History ({messages.length} message{messages.length !== 1 ? 's' : ''})
{!isExpanded && ` · ${summary}`}
<Text type="secondary" style={{ fontSize: 11 }}>
History ({messages.length})
</Text>
{isExpanded ? (
<UpOutlined style={{ color: '#8c8c8c', fontSize: 10 }} />
<UpOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
) : (
<DownOutlined style={{ color: '#8c8c8c', fontSize: 10 }} />
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
)}
<div style={{ flex: 1, borderTop: '1px dashed #d9d9d9' }} />
</div>
{/* Expanded View - Full Messages with subtle indent */}
{/* Expanded View - Full Messages */}
{isExpanded && (
<div
style={{
marginTop: 16,
paddingLeft: 16,
borderLeft: '1px solid #f0f0f0',
}}
>
<div style={{ marginTop: 12 }}>
{messages.map((msg, index) => (
<MessageCard
<MessageBlock
key={index}
message={msg}
defaultCollapsed={msg.content?.length > 500}
role={msg.role.toUpperCase()}
content={msg.content}
toolCalls={msg.toolCalls}
/>
))}
</div>

View file

@ -0,0 +1,69 @@
/**
* HistoryTree - Collapsible tree view for message history
* Shows arrow indicator and message count
*/
import { useState } from 'react';
import { Typography } from 'antd';
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import { ParsedMessage } from './prettyMessagesTypes';
import { SimpleMessageBlock } from './SimpleMessageBlock';
const { Text } = Typography;
interface HistoryTreeProps {
messages: ParsedMessage[];
}
export function HistoryTree({ messages }: HistoryTreeProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (messages.length === 0) {
return null;
}
return (
<div style={{ marginBottom: 12 }}>
{/* Clickable Header */}
<div
onClick={() => setIsExpanded(!isExpanded)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
cursor: 'pointer',
marginBottom: isExpanded ? 8 : 0,
}}
>
{isExpanded ? (
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
) : (
<RightOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
)}
<Text type="secondary" style={{ fontSize: 11 }}>
HISTORY ({messages.length} message{messages.length !== 1 ? 's' : ''})
</Text>
</div>
{/* Expanded Tree Content */}
{isExpanded && (
<div
style={{
paddingLeft: 16,
borderLeft: '1px solid #f0f0f0',
}}
>
{messages.map((msg, index) => (
<SimpleMessageBlock
key={index}
label={msg.role.toUpperCase()}
content={msg.content}
toolCalls={msg.toolCalls}
isCompact={true}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,78 @@
/**
* InputCard - Displays all input messages with token count and cost
* Datadog-style: header with icon/metrics, content below
*/
import { message } from 'antd';
import { ParsedMessage } from './prettyMessagesTypes';
import { SectionHeader } from './SectionHeader';
import { CollapsibleMessage } from './CollapsibleMessage';
import { HistoryTree } from './HistoryTree';
import { SimpleMessageBlock } from './SimpleMessageBlock';
interface InputCardProps {
messages: ParsedMessage[];
promptTokens?: number;
inputCost?: number;
}
export function InputCard({ messages, promptTokens, inputCost }: InputCardProps) {
if (messages.length === 0) {
return null;
}
// Separate system, history, and last message
const systemMessage = messages.find((m) => m.role === 'system');
const nonSystemMessages = messages.filter((m) => m.role !== 'system');
const lastMessage = nonSystemMessages.length > 0 ? nonSystemMessages[nonSystemMessages.length - 1] : null;
const historyMessages = nonSystemMessages.slice(0, -1);
const handleCopy = () => {
const content = JSON.stringify(messages, null, 2);
navigator.clipboard.writeText(content);
message.success('Input copied');
};
return (
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 6,
marginBottom: 12,
overflow: 'hidden',
}}
>
{/* Datadog-style Header */}
<SectionHeader
type="input"
tokens={promptTokens}
cost={inputCost}
onCopy={handleCopy}
/>
{/* Content */}
<div style={{ padding: '12px 14px' }}>
{/* System Message - Collapsible with arrow */}
{systemMessage && (
<CollapsibleMessage
label="SYSTEM"
content={systemMessage.content}
defaultExpanded={!!(systemMessage.content && systemMessage.content.length < 200)}
/>
)}
{/* History - Tree style, collapsed by default */}
{historyMessages.length > 0 && <HistoryTree messages={historyMessages} />}
{/* Last User Message - Always visible */}
{lastMessage && (
<SimpleMessageBlock
label={lastMessage.role.toUpperCase()}
content={lastMessage.content}
toolCalls={lastMessage.toolCalls}
/>
)}
</div>
</div>
);
}

View file

@ -208,6 +208,7 @@ export function LogDetailsDrawer({
onCopy={(data, label) => copyToClipboard(JSON.stringify(data, null, 2), label)}
getRawRequest={getRawRequest}
getFormattedResponse={getFormattedResponse}
logEntry={logEntry}
/>
{/* Guardrail Data - Show only if present */}
@ -341,6 +342,7 @@ interface RequestResponseSectionProps {
onCopy: (data: any, label: string) => void;
getRawRequest: () => any;
getFormattedResponse: () => any;
logEntry: LogEntry;
}
function RequestResponseSection({
@ -348,6 +350,7 @@ function RequestResponseSection({
onCopy,
getRawRequest,
getFormattedResponse,
logEntry,
}: RequestResponseSectionProps) {
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const [viewMode, setViewMode] = useState<'pretty' | 'json'>('pretty');
@ -358,6 +361,17 @@ function RequestResponseSection({
onCopy(data, label);
};
// Calculate input and output costs
// Assume average cost if not explicitly provided
const totalSpend = logEntry.spend || 0;
const promptTokens = logEntry.prompt_tokens || 0;
const completionTokens = logEntry.completion_tokens || 0;
const totalTokens = promptTokens + completionTokens;
// Estimate input/output costs proportionally if not available
const inputCost = totalTokens > 0 ? (totalSpend * promptTokens) / totalTokens : 0;
const outputCost = totalTokens > 0 ? (totalSpend * completionTokens) / totalTokens : 0;
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
@ -385,6 +399,12 @@ function RequestResponseSection({
<PrettyMessagesView
request={getRawRequest()}
response={getFormattedResponse()}
metrics={{
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
input_cost: inputCost,
output_cost: outputCost,
}}
/>
) : (
<Tabs

View file

@ -0,0 +1,104 @@
/**
* MessageBlock - Displays a single message with role label
* No colors, minimal gray styling
*/
import { useState } from 'react';
import { Typography, Button } from 'antd';
import { ToolCall } from './prettyMessagesTypes';
import { ToolCallBlock } from './ToolCallBlock';
const { Text } = Typography;
interface MessageBlockProps {
role: string;
content?: string;
toolCalls?: ToolCall[];
}
const TRUNCATE_LENGTH = 500;
export function MessageBlock({ role, content, toolCalls }: MessageBlockProps) {
const [isExpanded, setIsExpanded] = useState(false);
const hasContent = content && content.length > 0;
const hasToolCalls = toolCalls && toolCalls.length > 0;
const isLong = hasContent && content.length > TRUNCATE_LENGTH;
const shouldTruncate = isLong && !isExpanded;
// If no content and no tool calls, don't render anything
if (!hasContent && !hasToolCalls) {
return null;
}
return (
<div style={{ marginBottom: 12 }}>
{/* Role Label */}
<Text
type="secondary"
style={{
fontSize: 11,
display: 'block',
marginBottom: 4,
color: '#8c8c8c',
}}
>
{role}
</Text>
{/* Content */}
{hasContent && (
<div
style={{
fontSize: 13,
lineHeight: 1.6,
color: '#262626',
marginBottom: hasToolCalls ? 8 : 0,
}}
>
{shouldTruncate ? (
<>
{content.slice(0, TRUNCATE_LENGTH)}...
<Button
type="link"
size="small"
onClick={() => setIsExpanded(true)}
style={{ padding: '0 4px', fontSize: 12 }}
>
Show more
</Button>
</>
) : (
<>
{content}
{isLong && isExpanded && (
<Button
type="link"
size="small"
onClick={() => setIsExpanded(false)}
style={{
padding: '0 4px',
fontSize: 12,
display: 'block',
marginTop: 4,
}}
>
Show less
</Button>
)}
</>
)}
</div>
)}
{/* Tool Calls */}
{hasToolCalls && (
<div>
{toolCalls.map((tool, index) => (
<ToolCallBlock key={tool.id || index} tool={tool} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,79 @@
/**
* OutputCard - Displays output message with token count and cost
* Datadog-style: header with icon/metrics, content below
*/
import { Typography, message as antdMessage } from 'antd';
import { ParsedMessage } from './prettyMessagesTypes';
import { SectionHeader } from './SectionHeader';
import { SimpleMessageBlock } from './SimpleMessageBlock';
const { Text } = Typography;
interface OutputCardProps {
message: ParsedMessage | null;
completionTokens?: number;
outputCost?: number;
}
export function OutputCard({ message, completionTokens, outputCost }: OutputCardProps) {
const handleCopy = () => {
if (!message) return;
const content = JSON.stringify(message, null, 2);
navigator.clipboard.writeText(content);
antdMessage.success('Output copied');
};
if (!message) {
return (
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 6,
overflow: 'hidden',
}}
>
<SectionHeader
type="output"
tokens={completionTokens}
cost={outputCost}
onCopy={handleCopy}
/>
<div style={{ padding: '12px 14px' }}>
<Text type="secondary" style={{ fontSize: 13, fontStyle: 'italic' }}>
No response data available
</Text>
</div>
</div>
);
}
return (
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 6,
overflow: 'hidden',
}}
>
{/* Datadog-style Header */}
<SectionHeader
type="output"
tokens={completionTokens}
cost={outputCost}
onCopy={handleCopy}
/>
{/* Content */}
<div style={{ padding: '12px 14px' }}>
<SimpleMessageBlock
label="ASSISTANT"
content={message.content}
toolCalls={message.toolCalls}
/>
</div>
</div>
);
}

View file

@ -1,108 +1,41 @@
/**
* PrettyMessagesView - Chat-style view of request and response messages
* Replaces raw JSON with scannable, readable message cards
* PrettyMessagesView - Datadog-style view with Input/Output cards
* Two main cards showing request and response with token counts and costs
*/
import { Typography } from 'antd';
import { parseMessages } from './prettyMessagesUtils';
import { MessageCard } from './MessageCard';
import { HistorySection } from './HistorySection';
const { Text } = Typography;
import { InputCard } from './InputCard';
import { OutputCard } from './OutputCard';
interface PrettyMessagesViewProps {
request: any;
response: any;
metrics?: {
prompt_tokens?: number;
completion_tokens?: number;
input_cost?: number;
output_cost?: number;
};
}
export function PrettyMessagesView({ request, response }: PrettyMessagesViewProps) {
export function PrettyMessagesView({ request, response, metrics }: PrettyMessagesViewProps) {
const { requestMessages, responseMessage } = parseMessages(request, response);
// Separate system, history, and last user message
const systemMessage = requestMessages.find((m) => m.role === 'system');
const nonSystemMessages = requestMessages.filter((m) => m.role !== 'system');
const lastUserMessage =
nonSystemMessages.length > 0 ? nonSystemMessages[nonSystemMessages.length - 1] : null;
const historyMessages = nonSystemMessages.slice(0, -1);
return (
<div style={{ paddingTop: 4, paddingBottom: 16 }}>
{/* REQUEST SECTION */}
<div>
<Text
type="secondary"
style={{
fontSize: 12,
marginBottom: 16,
display: 'block',
}}
>
Request ({requestMessages.length} message{requestMessages.length !== 1 ? 's' : ''})
</Text>
{/* Input Card */}
<InputCard
messages={requestMessages}
promptTokens={metrics?.prompt_tokens}
inputCost={metrics?.input_cost}
/>
{/* System Message - Collapsed by default */}
{systemMessage && <MessageCard message={systemMessage} defaultCollapsed={true} />}
{/* History - Collapsed if > 0 messages */}
{historyMessages.length > 0 && <HistorySection messages={historyMessages} />}
{/* Last User Message - Always expanded */}
{lastUserMessage && lastUserMessage.role === 'user' && (
<MessageCard message={lastUserMessage} defaultCollapsed={false} />
)}
{/* Fallback if no messages */}
{requestMessages.length === 0 && (
<div
style={{
textAlign: 'center',
padding: 20,
color: '#8c8c8c',
fontStyle: 'italic',
fontSize: 13,
}}
>
No request messages available
</div>
)}
</div>
{/* Section Divider */}
<div
style={{
borderTop: '1px solid #f0f0f0',
margin: '20px 0',
paddingTop: 16,
}}
>
<Text
type="secondary"
style={{
fontSize: 12,
marginBottom: 16,
display: 'block',
}}
>
Response
</Text>
{/* RESPONSE SECTION */}
{responseMessage ? (
<MessageCard message={responseMessage} defaultCollapsed={false} showToolCalls />
) : (
<div
style={{
textAlign: 'center',
padding: 20,
color: '#8c8c8c',
fontStyle: 'italic',
fontSize: 13,
}}
>
Response data not available
</div>
)}
</div>
{/* Output Card */}
<OutputCard
message={responseMessage}
completionTokens={metrics?.completion_tokens}
outputCost={metrics?.output_cost}
/>
</div>
);
}

View file

@ -0,0 +1,67 @@
/**
* SectionHeader - Datadog-style header with icon, label, metrics, and copy
*/
import { Typography, Button, Tooltip } from 'antd';
import {
MessageOutlined,
ThunderboltOutlined,
CopyOutlined
} from '@ant-design/icons';
const { Text } = Typography;
interface SectionHeaderProps {
type: 'input' | 'output';
tokens?: number;
cost?: number;
onCopy: () => void;
}
export function SectionHeader({ type, tokens, cost, onCopy }: SectionHeaderProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderBottom: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
{/* Icon + Label */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{type === 'input' ? (
<MessageOutlined style={{ color: '#8c8c8c', fontSize: 14 }} />
) : (
<ThunderboltOutlined style={{ color: '#8c8c8c', fontSize: 14 }} />
)}
<Text strong style={{ fontSize: 13 }}>
{type === 'input' ? 'Input' : 'Output'}
</Text>
</div>
{/* Tokens */}
{tokens !== undefined && (
<Text type="secondary" style={{ fontSize: 12 }}>
Tokens: {tokens.toLocaleString()}
</Text>
)}
{/* Cost */}
{cost !== undefined && (
<Text type="secondary" style={{ fontSize: 12 }}>
Cost: ${cost.toFixed(6)}
</Text>
)}
</div>
{/* Copy Button */}
<Tooltip title="Copy">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={onCopy} />
</Tooltip>
</div>
);
}

View file

@ -0,0 +1,65 @@
/**
* SimpleMessageBlock - Simple message display without collapsing
* Used for messages in tree view and last user message
*/
import { Typography } from 'antd';
import { ToolCall } from './prettyMessagesTypes';
import { SimpleToolCallBlock } from './SimpleToolCallBlock';
const { Text } = Typography;
interface SimpleMessageBlockProps {
label: string;
content?: string;
toolCalls?: ToolCall[];
isCompact?: boolean;
}
export function SimpleMessageBlock({
label,
content,
toolCalls,
isCompact = false
}: SimpleMessageBlockProps) {
// Don't show "null" for empty content
const displayContent = content && content !== 'null' && content.length > 0 ? content : null;
const hasToolCalls = toolCalls && toolCalls.length > 0;
// If no content and no tool calls, don't render
if (!displayContent && !hasToolCalls) {
return null;
}
return (
<div style={{ marginBottom: isCompact ? 10 : 0 }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>
{label}
</Text>
{displayContent && (
<div
style={{
fontSize: 13,
lineHeight: 1.6,
color: '#262626',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
marginBottom: hasToolCalls ? 8 : 0,
}}
>
{displayContent}
</div>
)}
{/* Inline tool calls for assistant messages in history */}
{hasToolCalls && (
<div>
{toolCalls.map((tc, index) => (
<SimpleToolCallBlock key={tc.id || index} tool={tc} compact={isCompact} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,47 @@
/**
* SimpleToolCallBlock - Simple tool call display without copy button
* Used in compact/tree views
*/
import { Typography } from 'antd';
import { ToolCall } from './prettyMessagesTypes';
const { Text } = Typography;
interface SimpleToolCallBlockProps {
tool: ToolCall;
compact?: boolean;
}
export function SimpleToolCallBlock({ tool, compact = false }: SimpleToolCallBlockProps) {
return (
<div
style={{
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 4,
padding: compact ? '6px 10px' : '8px 12px',
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
}}
>
<Text strong style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
{tool.name}
</Text>
{Object.keys(tool.arguments).length > 0 && (
<div>
{Object.entries(tool.arguments).map(([key, value]) => (
<div key={key} style={{ marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{key}:{' '}
</Text>
<Text style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,78 @@
/**
* ToolCallBlock - Displays tool call with white background
* Minimal, monochrome styling
*/
import { useState } from 'react';
import { Typography, Button, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { ToolCall } from './prettyMessagesTypes';
const { Text } = Typography;
interface ToolCallBlockProps {
tool: ToolCall;
}
export function ToolCallBlock({ tool }: ToolCallBlockProps) {
const [isHovered, setIsHovered] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(tool.arguments, null, 2));
message.success('Tool arguments copied');
};
return (
<div
style={{
background: '#fff',
border: '1px solid #e8e8e8',
borderRadius: 4,
padding: '8px 12px',
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Tool Name Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Object.keys(tool.arguments).length > 0 ? 6 : 0,
}}
>
<Text strong style={{ fontSize: 12, color: '#262626' }}>
{tool.name}
</Text>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
style={{
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s',
}}
onClick={handleCopy}
/>
</div>
{/* Tool Arguments */}
{Object.keys(tool.arguments).length > 0 && (
<div>
{Object.entries(tool.arguments).map(([key, value]) => (
<div key={key} style={{ marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{key}:{' '}
</Text>
<Text style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text>
</div>
))}
</div>
)}
</div>
);
}

File diff suppressed because one or more lines are too long