Skip to content

Commit f07cffd

Browse files
authored
improvement(logs): added infinite scroll, markdown rendering, and individual block input to logs (#364)
* add performant infinite scroll * added markdown rendering in the logs * added individual block input to logs * fixed markdown render * consolidate redactApiKeys to utils * acknowledged PR comments
1 parent 1152a26 commit f07cffd

File tree

10 files changed

+462
-136
lines changed

10 files changed

+462
-136
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import ReactMarkdown from 'react-markdown'
2+
3+
export default function LogMarkdownRenderer({ content }: { content: string }) {
4+
// Process text to clean up unnecessary whitespace and formatting issues
5+
const processedContent = content
6+
.replace(/\n{2,}/g, '\n\n') // Replace multiple newlines with exactly double newlines
7+
.replace(/^(#{1,6})\s+(.+?)\n{2,}/gm, '$1 $2\n') // Reduce space after headings to single newline
8+
.replace(/^(#{1,6}.+)\n\n(-|\*)/gm, '$1\n$2') // Remove double newline between heading and list
9+
.trim()
10+
11+
const customComponents = {
12+
// Default component to ensure monospace font with minimal spacing
13+
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
14+
<p className="whitespace-pre-wrap font-mono text-sm leading-tight my-0.5">{children}</p>
15+
),
16+
17+
// Inline code - no background to maintain clean appearance
18+
code: ({
19+
inline,
20+
className,
21+
children,
22+
...props
23+
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
24+
return (
25+
<code className="font-mono text-sm" {...props}>
26+
{children}
27+
</code>
28+
)
29+
},
30+
31+
// Links - maintain monospace while adding subtle link styling
32+
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
33+
<a
34+
href={href}
35+
className="font-mono text-sm text-blue-600 dark:text-blue-400 hover:underline"
36+
target="_blank"
37+
rel="noopener noreferrer"
38+
{...props}
39+
>
40+
{children}
41+
</a>
42+
),
43+
44+
// Tighter lists with minimal spacing
45+
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
46+
<ul className="list-disc pl-5 font-mono text-sm -mt-1.5 mb-1 leading-none">{children}</ul>
47+
),
48+
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
49+
<ol className="list-decimal pl-5 font-mono text-sm -mt-1.5 mb-1 leading-none">{children}</ol>
50+
),
51+
li: ({ children }: React.HTMLAttributes<HTMLLIElement>) => (
52+
<li className="font-mono text-sm mb-0 leading-tight">{children}</li>
53+
),
54+
55+
// Keep blockquotes minimal
56+
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
57+
<blockquote className="font-mono text-sm my-0">{children}</blockquote>
58+
),
59+
60+
// Make headings compact with minimal spacing after
61+
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
62+
<h1 className="font-mono text-sm font-medium mt-2 mb-0">{children}</h1>
63+
),
64+
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
65+
<h2 className="font-mono text-sm font-medium mt-2 mb-0">{children}</h2>
66+
),
67+
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
68+
<h3 className="font-mono text-sm font-medium mt-1.5 mb-0">{children}</h3>
69+
),
70+
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
71+
<h4 className="font-mono text-sm font-medium mt-1.5 mb-0">{children}</h4>
72+
),
73+
}
74+
75+
return (
76+
<div className="text-sm whitespace-pre-wrap w-full overflow-visible font-mono leading-tight [&>ul]:mt-0 [&>h2+ul]:-mt-2.5 [&>h3+ul]:-mt-2.5">
77+
<ReactMarkdown components={customComponents}>{processedContent}</ReactMarkdown>
78+
</div>
79+
)
80+
}

apps/sim/app/w/logs/components/sidebar/sidebar.tsx

Lines changed: 134 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
'use client'
22

33
import { useEffect, useMemo, useRef, useState } from 'react'
4-
import { ChevronDown, ChevronUp, Code, X } from 'lucide-react'
4+
import { ChevronDown, ChevronUp, X } from 'lucide-react'
55
import { Button } from '@/components/ui/button'
66
import { CopyButton } from '@/components/ui/copy-button'
77
import { ScrollArea } from '@/components/ui/scroll-area'
88
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
9+
import { redactApiKeys } from '@/lib/utils'
910
import { WorkflowLog } from '@/app/w/logs/stores/types'
1011
import { formatDate } from '@/app/w/logs/utils/format-date'
1112
import { formatCost } from '@/providers/utils'
1213
import { ToolCallsDisplay } from '../tool-calls/tool-calls-display'
1314
import { TraceSpansDisplay } from '../trace-spans/trace-spans-display'
15+
import LogMarkdownRenderer from './components/markdown-renderer'
1416

1517
interface LogSidebarProps {
1618
log: WorkflowLog | null
@@ -49,38 +51,127 @@ const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string
4951
/**
5052
* Formats JSON content for display, handling multiple JSON objects separated by '--'
5153
*/
52-
const formatJsonContent = (content: string): React.ReactNode => {
54+
const formatJsonContent = (content: string, blockInput?: Record<string, any>): React.ReactNode => {
5355
// Look for a pattern like "Block Agent 1 (agent):" to separate system comment from content
5456
const blockPattern = /^(Block .+?\(.+?\):)\s*/
5557
const match = content.match(blockPattern)
5658

5759
if (match) {
5860
const systemComment = match[1]
5961
const actualContent = content.substring(match[0].length).trim()
60-
const { formatted } = tryPrettifyJson(actualContent)
62+
const { isJson, formatted } = tryPrettifyJson(actualContent)
6163

6264
return (
63-
<div className="w-full">
64-
<div className="text-sm font-medium mb-2 text-muted-foreground">{systemComment}</div>
65-
<div className="bg-secondary/30 p-3 rounded-md relative group">
66-
<CopyButton text={formatted} className="h-7 w-7 z-10" />
67-
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
68-
{formatted}
69-
</pre>
70-
</div>
71-
</div>
65+
<BlockContentDisplay
66+
systemComment={systemComment}
67+
formatted={formatted}
68+
isJson={isJson}
69+
blockInput={blockInput}
70+
/>
7271
)
7372
}
7473

7574
// If no system comment pattern found, show the whole content
76-
const { formatted } = tryPrettifyJson(content)
75+
const { isJson, formatted } = tryPrettifyJson(content)
7776

7877
return (
7978
<div className="bg-secondary/30 p-3 rounded-md relative group w-full">
8079
<CopyButton text={formatted} className="h-7 w-7 z-10" />
81-
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
82-
{formatted}
83-
</pre>
80+
{isJson ? (
81+
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
82+
{formatted}
83+
</pre>
84+
) : (
85+
<LogMarkdownRenderer content={formatted} />
86+
)}
87+
</div>
88+
)
89+
}
90+
91+
const BlockContentDisplay = ({
92+
systemComment,
93+
formatted,
94+
isJson,
95+
blockInput,
96+
}: {
97+
systemComment: string
98+
formatted: string
99+
isJson: boolean
100+
blockInput?: Record<string, any>
101+
}) => {
102+
const [activeTab, setActiveTab] = useState<'output' | 'input'>(blockInput ? 'output' : 'output')
103+
104+
const redactedBlockInput = useMemo(() => {
105+
return blockInput ? redactApiKeys(blockInput) : undefined
106+
}, [blockInput])
107+
108+
const redactedOutput = useMemo(() => {
109+
if (!isJson) return formatted
110+
111+
try {
112+
const parsedOutput = JSON.parse(formatted)
113+
const redactedJson = redactApiKeys(parsedOutput)
114+
return JSON.stringify(redactedJson, null, 2)
115+
} catch (e) {
116+
return formatted
117+
}
118+
}, [formatted, isJson])
119+
120+
return (
121+
<div className="w-full">
122+
<div className="text-sm font-medium text-muted-foreground mb-2">{systemComment}</div>
123+
124+
{/* Tabs for switching between output and input */}
125+
{redactedBlockInput && (
126+
<div className="flex space-x-1 mb-2">
127+
<button
128+
onClick={() => setActiveTab('output')}
129+
className={`px-3 py-1 text-xs rounded-md transition-colors ${
130+
activeTab === 'output'
131+
? 'bg-secondary text-foreground'
132+
: 'hover:bg-secondary/50 text-muted-foreground'
133+
}`}
134+
>
135+
Output
136+
</button>
137+
<button
138+
onClick={() => setActiveTab('input')}
139+
className={`px-3 py-1 text-xs rounded-md transition-colors ${
140+
activeTab === 'input'
141+
? 'bg-secondary text-foreground'
142+
: 'hover:bg-secondary/50 text-muted-foreground'
143+
}`}
144+
>
145+
Input
146+
</button>
147+
</div>
148+
)}
149+
150+
{/* Content based on active tab */}
151+
<div className="bg-secondary/30 p-3 rounded-md relative group">
152+
{activeTab === 'output' ? (
153+
<>
154+
<CopyButton text={redactedOutput} className="h-7 w-7 z-10" />
155+
{isJson ? (
156+
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-visible">
157+
{redactedOutput}
158+
</pre>
159+
) : (
160+
<LogMarkdownRenderer content={redactedOutput} />
161+
)}
162+
</>
163+
) : (
164+
<>
165+
<CopyButton
166+
text={JSON.stringify(redactedBlockInput, null, 2)}
167+
className="h-7 w-7 z-10"
168+
/>
169+
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-visible">
170+
{JSON.stringify(redactedBlockInput, null, 2)}
171+
</pre>
172+
</>
173+
)}
174+
</div>
84175
</div>
85176
)
86177
}
@@ -115,10 +206,29 @@ export function Sidebar({
115206

116207
const formattedContent = useMemo(() => {
117208
if (!log) return null
118-
return formatJsonContent(log.message)
209+
210+
let blockInput: Record<string, any> | undefined = undefined
211+
212+
if (log.metadata?.blockInput) {
213+
blockInput = log.metadata.blockInput
214+
} else if (log.metadata?.traceSpans) {
215+
const blockIdMatch = log.message.match(/Block .+?(\d+)/i)
216+
const blockId = blockIdMatch ? blockIdMatch[1] : null
217+
218+
if (blockId) {
219+
const matchingSpan = log.metadata.traceSpans.find(
220+
(span) => span.blockId === blockId || span.name.includes(`Block ${blockId}`)
221+
)
222+
223+
if (matchingSpan && matchingSpan.input) {
224+
blockInput = matchingSpan.input
225+
}
226+
}
227+
}
228+
229+
return formatJsonContent(log.message, blockInput)
119230
}, [log])
120231

121-
// Reset scroll position when log changes
122232
useEffect(() => {
123233
if (scrollAreaRef.current) {
124234
scrollAreaRef.current.scrollTop = 0
@@ -297,8 +407,11 @@ export function Sidebar({
297407
</div>
298408

299409
{/* Content */}
300-
<ScrollArea className="h-[calc(100vh-64px-49px)] w-full" ref={scrollAreaRef}>
301-
<div className="p-4 space-y-4 w-full overflow-hidden pr-6">
410+
<ScrollArea
411+
className="h-[calc(100vh-64px-49px)] w-full overflow-y-auto"
412+
ref={scrollAreaRef}
413+
>
414+
<div className="p-4 space-y-4 w-full pr-6">
302415
{/* Timestamp */}
303416
<div>
304417
<h3 className="text-xs font-medium text-muted-foreground mb-1">Timestamp</h3>
@@ -374,7 +487,7 @@ export function Sidebar({
374487
</div>
375488
)}
376489

377-
{/* Message Content - MOVED ABOVE the Trace Spans and Cost */}
490+
{/* Message Content */}
378491
<div className="pb-2 w-full">
379492
<h3 className="text-xs font-medium text-muted-foreground mb-1">Message</h3>
380493
<div className="w-full">{formattedContent}</div>

0 commit comments

Comments
 (0)