v1 - new pretty view

This commit is contained in:
Ishaan Jaffer 2026-01-30 17:11:09 -08:00
parent 2156db9f06
commit e1b1263da1
8 changed files with 681 additions and 43 deletions

View file

@ -0,0 +1,77 @@
/**
* HistorySection - Collapsible section for displaying message history
* Shows a summary when collapsed, full messages when expanded
*/
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';
const { Text } = Typography;
interface HistorySectionProps {
messages: ParsedMessage[];
}
export function HistorySection({ messages }: HistorySectionProps) {
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
onClick={() => setIsExpanded(!isExpanded)}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
gap: 12,
}}
>
<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>
{isExpanded ? (
<UpOutlined style={{ color: '#8c8c8c', fontSize: 10 }} />
) : (
<DownOutlined style={{ color: '#8c8c8c', fontSize: 10 }} />
)}
<div style={{ flex: 1, borderTop: '1px dashed #d9d9d9' }} />
</div>
{/* Expanded View - Full Messages with subtle indent */}
{isExpanded && (
<div
style={{
marginTop: 16,
paddingLeft: 16,
borderLeft: '1px solid #f0f0f0',
}}
>
{messages.map((msg, index) => (
<MessageCard
key={index}
message={msg}
defaultCollapsed={msg.content?.length > 500}
/>
))}
</div>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { Drawer, Typography, Button, Descriptions, Card, Tag, Tabs, Alert, message, Collapse } from "antd";
import { Drawer, Typography, Button, Descriptions, Card, Tag, Tabs, Alert, message, Collapse, Radio } from "antd";
import { CopyOutlined } from "@ant-design/icons";
import moment from "moment";
import { LogEntry } from "../columns";
@ -27,6 +27,7 @@ import {
MESSAGE_REQUEST_ID_COPIED,
} from "./constants";
import { ToolsSection } from "../ToolsSection";
import { PrettyMessagesView } from "./PrettyMessagesView";
const { Text } = Typography;
@ -349,6 +350,7 @@ function RequestResponseSection({
getFormattedResponse,
}: RequestResponseSectionProps) {
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const [viewMode, setViewMode] = useState<'pretty' | 'json'>('pretty');
const handleCopy = () => {
const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
@ -367,47 +369,66 @@ function RequestResponseSection({
label: <h3 className="text-lg font-medium text-gray-900">Request & Response</h3>,
children: (
<div style={{ padding: "0 24px" }}>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
tabBarExtraContent={
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={handleCopy}
disabled={activeTab === TAB_RESPONSE && !hasResponse}
>
Copy
</Button>
}
items={[
{
key: TAB_REQUEST,
label: "Request",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
<JsonViewer data={getRawRequest()} mode="formatted" />
</div>
),
},
{
key: TAB_RESPONSE,
label: "Response",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
{hasResponse ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
Response data not available
</div>
)}
</div>
),
},
]}
/>
{/* View Mode Toggle - Top Right */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Radio.Group
size="small"
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
>
<Radio.Button value="pretty">Pretty</Radio.Button>
<Radio.Button value="json">JSON</Radio.Button>
</Radio.Group>
</div>
{viewMode === 'pretty' ? (
<PrettyMessagesView
request={getRawRequest()}
response={getFormattedResponse()}
/>
) : (
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
tabBarExtraContent={
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={handleCopy}
disabled={activeTab === TAB_RESPONSE && !hasResponse}
>
Copy
</Button>
}
items={[
{
key: TAB_REQUEST,
label: "Request",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
<JsonViewer data={getRawRequest()} mode="formatted" />
</div>
),
},
{
key: TAB_RESPONSE,
label: "Response",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
{hasResponse ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
Response data not available
</div>
)}
</div>
),
},
]}
/>
)}
</div>
),
},

View file

@ -0,0 +1,199 @@
/**
* MessageCard - Display individual message with role-based styling
* Features: collapsible long content, copy button, tool calls display
*/
import { useState } from 'react';
import { Button, Typography, message as antdMessage } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { ParsedMessage } from './prettyMessagesTypes';
import { ROLE_STYLES } from './prettyMessagesUtils';
import { ToolCallCard } from './ToolCallCard';
const { Text } = Typography;
interface MessageCardProps {
message: ParsedMessage;
defaultCollapsed?: boolean;
showToolCalls?: boolean;
}
const TRUNCATE_LENGTH = 500;
export function MessageCard({
message,
defaultCollapsed = false,
showToolCalls = false,
}: MessageCardProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [isHovered, setIsHovered] = useState(false);
const style = ROLE_STYLES[message.role] || ROLE_STYLES.user;
const content = message.content || '';
const isLong = content.length > TRUNCATE_LENGTH;
const shouldTruncate = isCollapsed && isLong;
// Don't show empty content for assistant messages with tool calls
const hasContent = content.length > 0;
const hasToolCalls = showToolCalls && message.toolCalls && message.toolCalls.length > 0;
// If assistant message with no content but has tool calls, skip null display
if (message.role === 'assistant' && !hasContent && hasToolCalls) {
return (
<div
style={{ marginBottom: 16 }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Role Label Row */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text
strong
style={{
fontSize: 11,
color: style.labelColor,
letterSpacing: '0.5px',
}}
>
{style.label}
</Text>
</div>
{/* Tool Calls with left border */}
<div
style={{
borderLeft: `2px solid ${style.borderColor}`,
paddingLeft: 12,
}}
>
{message.toolCalls!.map((tool, index) => (
<ToolCallCard key={tool.id || index} tool={tool} />
))}
</div>
</div>
);
}
const handleCopy = () => {
navigator.clipboard.writeText(content);
antdMessage.success('Message copied');
};
return (
<div
style={{ marginBottom: 16 }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Role Label Row */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text
strong
style={{
fontSize: 11,
color: style.labelColor,
letterSpacing: '0.5px',
}}
>
{style.label}
{isLong && (
<Text type="secondary" style={{ marginLeft: 8, fontWeight: 'normal', fontSize: 11 }}>
({content.length.toLocaleString()} chars)
</Text>
)}
</Text>
{/* Copy Button - Show on hover */}
{hasContent && (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
style={{
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s',
}}
onClick={handleCopy}
/>
)}
</div>
{/* Content with left border accent */}
{hasContent && (
<div
style={{
borderLeft: `2px solid ${style.borderColor}`,
paddingLeft: 12,
fontSize: 13,
lineHeight: 1.6,
color: '#262626',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{shouldTruncate ? (
<>
{content.slice(0, TRUNCATE_LENGTH)}...
<Button
type="link"
size="small"
onClick={() => setIsCollapsed(false)}
style={{ padding: '0 4px', fontSize: 12 }}
>
Show more
</Button>
</>
) : (
<>
{content}
{isLong && !isCollapsed && (
<Button
type="link"
size="small"
onClick={() => setIsCollapsed(true)}
style={{
padding: '0 4px',
display: 'block',
marginTop: 4,
fontSize: 12,
}}
>
Show less
</Button>
)}
</>
)}
</div>
)}
{/* Tool Calls (for assistant messages) */}
{hasToolCalls && hasContent && (
<div
style={{
borderLeft: `2px solid ${style.borderColor}`,
paddingLeft: 12,
marginTop: 8,
}}
>
{message.toolCalls!.map((tool, index) => (
<ToolCallCard key={tool.id || index} tool={tool} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,108 @@
/**
* PrettyMessagesView - Chat-style view of request and response messages
* Replaces raw JSON with scannable, readable message cards
*/
import { Typography } from 'antd';
import { parseMessages } from './prettyMessagesUtils';
import { MessageCard } from './MessageCard';
import { HistorySection } from './HistorySection';
const { Text } = Typography;
interface PrettyMessagesViewProps {
request: any;
response: any;
}
export function PrettyMessagesView({ request, response }: 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>
{/* 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>
</div>
);
}

View file

@ -0,0 +1,79 @@
/**
* ToolCallCard - Display tool call information inline in assistant messages
*/
import { useState } from 'react';
import { Button, Typography, message } from 'antd';
import { CopyOutlined, ToolOutlined } from '@ant-design/icons';
import { ToolCall } from './prettyMessagesTypes';
const { Text } = Typography;
interface ToolCallCardProps {
tool: ToolCall;
}
export function ToolCallCard({ tool }: ToolCallCardProps) {
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: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 4,
padding: '8px 12px',
marginBottom: 8,
fontFamily: 'monospace',
fontSize: 12,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Tool 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 - Simple key: value format */}
{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 code style={{ background: 'transparent', fontSize: 12 }}>
{JSON.stringify(value)}
</Text>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
/**
* Type definitions for pretty messages view
*/
export interface ParsedMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
toolCalls?: ToolCall[];
toolCallId?: string;
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, any>;
}
export interface ParsedMessages {
requestMessages: ParsedMessage[];
responseMessage: ParsedMessage | null;
}
export interface RoleStyle {
background: string;
borderColor: string;
label: string;
labelColor: string;
}

View file

@ -0,0 +1,126 @@
/**
* Utility functions for parsing and formatting messages for pretty view
*/
import { ParsedMessage, ParsedMessages, RoleStyle } from './prettyMessagesTypes';
/**
* Role color styles for message cards - minimal, professional design
* Color only used for labels and left border accent
*/
export const ROLE_STYLES: Record<string, RoleStyle> = {
system: {
background: 'transparent',
borderColor: '#8c8c8c',
label: 'SYSTEM',
labelColor: '#8c8c8c',
},
user: {
background: 'transparent',
borderColor: '#1677ff',
label: 'USER',
labelColor: '#1677ff',
},
assistant: {
background: 'transparent',
borderColor: '#52c41a',
label: 'ASSISTANT',
labelColor: '#52c41a',
},
tool: {
background: 'transparent',
borderColor: '#fa8c16',
label: 'TOOL RESULT',
labelColor: '#fa8c16',
},
};
/**
* Parse request messages and response message from log data
*/
export const parseMessages = (request: any, response: any): ParsedMessages => {
// Parse request messages
const requestMessages: ParsedMessage[] = [];
if (request?.messages && Array.isArray(request.messages)) {
request.messages.forEach((msg: any) => {
requestMessages.push({
role: msg.role || 'user',
content: parseMessageContent(msg.content),
toolCallId: msg.tool_call_id,
});
});
}
// Parse response message
let responseMessage: ParsedMessage | null = null;
const responseMsg = response?.choices?.[0]?.message;
if (responseMsg) {
responseMessage = {
role: responseMsg.role || 'assistant',
content: responseMsg.content || '',
toolCalls: parseToolCalls(responseMsg.tool_calls),
};
}
return { requestMessages, responseMessage };
};
/**
* Parse message content - handle strings and content arrays (for vision, etc.)
*/
const parseMessageContent = (content: any): string => {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
// Handle content arrays (vision API format)
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item.type === 'text') return item.text;
if (item.type === 'image_url') return '[Image]';
return JSON.stringify(item);
})
.join('\n');
}
// Fallback to JSON string for complex content
return JSON.stringify(content);
};
/**
* Parse tool calls from response message
*/
const parseToolCalls = (toolCalls: any[]): Array<{
id: string;
name: string;
arguments: Record<string, any>;
}> | undefined => {
if (!toolCalls || !Array.isArray(toolCalls)) return undefined;
return toolCalls.map((tc) => ({
id: tc.id || '',
name: tc.function?.name || 'unknown',
arguments: parseToolArguments(tc.function?.arguments),
}));
};
/**
* Parse tool arguments - handle both string and object formats
*/
const parseToolArguments = (args: any): Record<string, any> => {
if (!args) return {};
if (typeof args === 'string') {
try {
return JSON.parse(args);
} catch {
return { raw: args };
}
}
return args;
};

File diff suppressed because one or more lines are too long