improvement: custom tools modal, logs-details (#2283)

This commit is contained in:
Emir Karabeg
2025-12-09 21:50:09 -08:00
committed by GitHub
parent f421f27d3f
commit 0713580862
7 changed files with 554 additions and 234 deletions

View File

@@ -8,6 +8,7 @@ import clsx from 'clsx'
import { Button, ChevronDown } from '@/components/emcn'
import type { TraceSpan } from '@/stores/logs/filters/types'
import '@/components/emcn/components/code/code.css'
import { WorkflowIcon } from '@/components/icons'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getBlock, getBlockByToolName } from '@/blocks'
@@ -120,6 +121,14 @@ function getBlockColor(type: string): string {
return '#2FA1FF'
case 'api':
return '#2F55FF'
case 'loop':
case 'loop-iteration':
return '#2FB3FF'
case 'parallel':
case 'parallel-iteration':
return '#FEE12B'
case 'workflow':
return '#705335'
default:
return '#6b7280'
}
@@ -134,12 +143,15 @@ function getBlockIconAndColor(type: string): {
} {
const lowerType = type.toLowerCase()
if (lowerType === 'loop') {
if (lowerType === 'loop' || lowerType === 'loop-iteration') {
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
}
if (lowerType === 'parallel') {
if (lowerType === 'parallel' || lowerType === 'parallel-iteration') {
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
}
if (lowerType === 'workflow') {
return { icon: WorkflowIcon, bgColor: '#705335' }
}
const blockType = lowerType === 'model' ? 'agent' : lowerType
const blockConfig = getBlock(blockType)
@@ -289,15 +301,11 @@ function InputOutputSection({
{isExpanded && (
<div>
{isError && typeof data === 'object' && data !== null && 'error' in data ? (
<div
className='rounded-[6px] px-[10px] py-[8px]'
style={{
backgroundColor: 'var(--terminal-status-error-bg)',
color: 'var(--text-error)',
}}
>
<div className='font-medium text-[12px]'>Error</div>
<div className='mt-[4px] text-[12px]'>{(data as { error: string }).error}</div>
<div className='rounded-[4px] border border-[rgba(234,67,53,0.24)] bg-[rgba(234,67,53,0.08)] px-[10px] py-[8px]'>
<div className='font-medium text-[#EA4335] text-[12px]'>Error</div>
<div className='mt-[4px] text-[#FF8076] text-[12px]'>
{(data as { error: string }).error}
</div>
</div>
) : (
<div className='code-editor-theme overflow-hidden rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
@@ -313,6 +321,116 @@ function InputOutputSection({
)
}
interface NestedBlockItemProps {
span: TraceSpan
parentId: string
index: number
expandedSections: Set<string>
onToggle: (section: string) => void
workflowStartTime: number
totalDuration: number
}
/**
* Recursive component for rendering nested blocks at any depth
*/
function NestedBlockItem({
span,
parentId,
index,
expandedSections,
onToggle,
workflowStartTime,
totalDuration,
}: NestedBlockItemProps): React.ReactNode {
const spanId = span.id || `${parentId}-nested-${index}`
const isError = span.status === 'error'
const toolBlock =
span.type?.toLowerCase() === 'tool' && span.name ? getBlockByToolName(span.name) : null
const { icon: SpanIcon, bgColor } = toolBlock
? { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
: getBlockIconAndColor(span.type)
return (
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{SpanIcon && <SpanIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
</div>
<span
className='font-medium text-[12px]'
style={{
color: isError ? 'var(--text-error)' : 'var(--text-secondary)',
}}
>
{span.name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(span.duration || 0)}
</span>
</div>
<ProgressBar
span={span}
childSpans={span.children}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{span.input && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={`${spanId}-input`}
sectionType='input'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
{span.input && span.output && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{span.output && (
<InputOutputSection
label={isError ? 'Error' : 'Output'}
data={span.output}
isError={isError}
spanId={`${spanId}-output`}
sectionType='output'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
{/* Recursively render children */}
{span.children && span.children.length > 0 && (
<div className='mt-[8px] flex flex-col gap-[16px] border-[var(--border)] border-l-2 pl-[10px]'>
{span.children.map((child, childIndex) => (
<NestedBlockItem
key={child.id || `${spanId}-child-${childIndex}`}
span={child}
parentId={spanId}
index={childIndex}
expandedSections={expandedSections}
onToggle={onToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
))}
</div>
)}
</div>
)
}
interface TraceSpanItemProps {
span: TraceSpan
totalDuration: number
@@ -346,11 +464,22 @@ function TraceSpanItem({
const hasOutput = Boolean(span.output)
const isError = span.status === 'error'
const inlineChildTypes = new Set(['tool', 'model'])
const inlineChildren =
span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const otherChildren =
span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const inlineChildTypes = new Set([
'tool',
'model',
'loop-iteration',
'parallel-iteration',
'workflow',
])
// For workflow-in-workflow blocks, all children should be rendered inline/nested
const isWorkflowBlock = span.type?.toLowerCase() === 'workflow'
const inlineChildren = isWorkflowBlock
? span.children || []
: span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const otherChildren = isWorkflowBlock
? []
: span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const toolCallSpans = useMemo(() => {
if (!hasToolCalls) return []
@@ -502,7 +631,14 @@ function TraceSpanItem({
<ProgressBar
span={childSpan}
childSpans={undefined}
childSpans={
childSpan.type?.toLowerCase() === 'loop-iteration' ||
childSpan.type?.toLowerCase() === 'parallel-iteration' ||
childSpan.type?.toLowerCase() === 'workflow' ||
(isWorkflowBlock && childSpan.children && childSpan.children.length > 0)
? childSpan.children
: undefined
}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
@@ -534,6 +670,29 @@ function TraceSpanItem({
onToggle={handleSectionToggle}
/>
)}
{/* Render nested blocks for loop/parallel iterations, nested workflows, and workflow block children */}
{(childSpan.type?.toLowerCase() === 'loop-iteration' ||
childSpan.type?.toLowerCase() === 'parallel-iteration' ||
childSpan.type?.toLowerCase() === 'workflow' ||
isWorkflowBlock) &&
childSpan.children &&
childSpan.children.length > 0 && (
<div className='mt-[8px] flex flex-col gap-[16px] border-[var(--border)] border-l-2 pl-[10px]'>
{childSpan.children.map((nestedChild, nestedIndex) => (
<NestedBlockItem
key={nestedChild.id || `${childId}-nested-${nestedIndex}`}
span={nestedChild}
parentId={childId}
index={nestedIndex}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
))}
</div>
)}
</div>
</div>
)

View File

@@ -6,10 +6,12 @@ import { Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import type { LogStatus } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatDate, StatusBadge, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useLogDetailsUIStore } from '@/stores/logs/store'
interface LogDetailsProps {
/** The log to display details for */
@@ -45,6 +47,8 @@ export function LogDetails({
}: LogDetailsProps) {
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const { handleMouseDown } = useLogDetailsResize()
useEffect(() => {
if (scrollAreaRef.current) {
@@ -103,234 +107,255 @@ export function LogDetails({
}, [log])
return (
<div
className={`absolute top-[0px] right-0 bottom-0 z-50 w-[384px] transform overflow-hidden border-l bg-[var(--surface-1)] shadow-lg transition-transform duration-200 ease-out ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
aria-label='Log details sidebar'
>
{log && (
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
{/* Header */}
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Log Details</h2>
<div className='flex items-center gap-[1px]'>
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
aria-label='Previous log'
>
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
</Button>
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
aria-label='Next log'
>
<ChevronUp className='h-[14px] w-[14px]' />
</Button>
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<>
{/* Resize Handle - positioned outside the panel */}
{isOpen && (
<div
className='absolute top-0 bottom-0 z-[60] w-[8px] cursor-ew-resize'
style={{ right: `${panelWidth - 4}px` }}
onMouseDown={handleMouseDown}
role='separator'
aria-label='Resize log details panel'
aria-orientation='vertical'
/>
)}
{/* Content - Scrollable */}
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='flex flex-col gap-[10px] pb-[16px]'>
{/* Timestamp & Workflow Row */}
<div className='flex items-center gap-[16px] px-[1px]'>
{/* Timestamp Card */}
<div className='flex w-[140px] flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Timestamp
<div
className={`absolute top-[0px] right-0 bottom-0 z-50 transform overflow-hidden border-l bg-[var(--surface-1)] shadow-md transition-transform duration-200 ease-out ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
style={{ width: `${panelWidth}px` }}
aria-label='Log details sidebar'
>
{log && (
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
{/* Header */}
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Log Details</h2>
<div className='flex items-center gap-[1px]'>
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
aria-label='Previous log'
>
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
</Button>
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
aria-label='Next log'
>
<ChevronUp className='h-[14px] w-[14px]' />
</Button>
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
{/* Content - Scrollable */}
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='flex flex-col gap-[10px] pb-[16px]'>
{/* Timestamp & Workflow Row */}
<div className='flex items-center gap-[16px] px-[1px]'>
{/* Timestamp Card */}
<div className='flex w-[140px] flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Timestamp
</div>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{formattedTimestamp?.compactDate || 'N/A'}
</span>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{formattedTimestamp?.compactTime || 'N/A'}
</span>
</div>
</div>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{formattedTimestamp?.compactDate || 'N/A'}
{/* Workflow Card */}
{log.workflow && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</div>
<div className='flex items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow.name}
</span>
</div>
</div>
)}
</div>
{/* Execution ID */}
{log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Execution ID
</span>
<span className='truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.executionId}
</span>
</div>
)}
{/* Details Section */}
<div className='flex flex-col'>
{/* Level */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Level
</span>
<StatusBadge status={logStatus} />
</div>
{/* Trigger */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Trigger
</span>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
</span>
)}
</div>
{/* Duration */}
<div className='flex h-[48px] items-center justify-between p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Duration
</span>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{formattedTimestamp?.compactTime || 'N/A'}
{log.duration || ''}
</span>
</div>
</div>
{/* Workflow Card */}
{log.workflow && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</div>
<div className='flex items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow.name}
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
</span>
<button
onClick={() => setIsFrozenCanvasOpen(true)}
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--c-2A2A2A)]'
>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
View Snapshot
</span>
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
</button>
</div>
)}
{/* Workflow Execution - Trace Spans */}
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{/* Files */}
{log.files && log.files.length > 0 && (
<FileCards files={log.files} isExecutionFile />
)}
{/* Cost Breakdown */}
{hasCostInfo && (
<div className='flex flex-col gap-[8px]'>
<span className='px-[1px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Cost Breakdown
</span>
<div className='flex flex-col gap-[4px] rounded-[6px] border border-[var(--border)]'>
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Base Execution:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(BASE_EXECUTION_CHARGE)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Model Input:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.input || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Model Output:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.output || 0)}
</span>
</div>
</div>
<div className='border-[var(--border)] border-t' />
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Total:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Tokens:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
out
</span>
</div>
</div>
</div>
<div className='flex items-center justify-center rounded-[6px] bg-[var(--surface-2)] p-[8px] text-center'>
<p className='font-medium text-[11px] text-[var(--text-subtle)]'>
Total cost includes a base execution charge of{' '}
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</div>
)}
{/* Execution ID */}
{log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Execution ID
</span>
<span className='truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.executionId}
</span>
</div>
)}
{/* Details Section */}
<div className='flex flex-col'>
{/* Level */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Level</span>
<StatusBadge status={logStatus} />
</div>
{/* Trigger */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Trigger
</span>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
<span className='font-medium text-[12px] text-[var(--text-secondary)]'></span>
)}
</div>
{/* Duration */}
<div className='flex h-[48px] items-center justify-between p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Duration
</span>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{log.duration || '—'}
</span>
</div>
</div>
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
</span>
<button
onClick={() => setIsFrozenCanvasOpen(true)}
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--c-2A2A2A)]'
>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
View Snapshot
</span>
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
</button>
</div>
)}
{/* Workflow Execution - Trace Spans */}
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{/* Files */}
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}
{/* Cost Breakdown */}
{hasCostInfo && (
<div className='flex flex-col gap-[8px]'>
<span className='px-[1px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Cost Breakdown
</span>
<div className='flex flex-col gap-[4px] rounded-[6px] border border-[var(--border)]'>
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Base Execution:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(BASE_EXECUTION_CHARGE)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Model Input:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.input || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Model Output:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.output || 0)}
</span>
</div>
</div>
<div className='border-[var(--border)] border-t' />
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Total:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Tokens:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
out
</span>
</div>
</div>
</div>
<div className='flex items-center justify-center rounded-[6px] bg-[var(--surface-2)] p-[8px] text-center'>
<p className='font-medium text-[11px] text-[var(--text-subtle)]'>
Total cost includes a base execution charge of{' '}
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</div>
)}
{/* Frozen Canvas Modal */}
{log?.executionId && (
<FrozenCanvas
executionId={log.executionId}
traceSpans={log.executionData?.traceSpans}
isModal
isOpen={isFrozenCanvasOpen}
onClose={() => setIsFrozenCanvasOpen(false)}
/>
)}
</div>
{/* Frozen Canvas Modal */}
{log?.executionId && (
<FrozenCanvas
executionId={log.executionId}
traceSpans={log.executionData?.traceSpans}
isModal
isOpen={isFrozenCanvasOpen}
onClose={() => setIsFrozenCanvasOpen(false)}
/>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,2 @@
export { useLogDetailsResize } from './use-log-details-resize'
export { useSearchState } from './use-search-state'

View File

@@ -0,0 +1,62 @@
import { useCallback, useEffect, useState } from 'react'
import {
MAX_LOG_DETAILS_WIDTH,
MIN_LOG_DETAILS_WIDTH,
useLogDetailsUIStore,
} from '@/stores/logs/store'
/**
* Hook for handling log details panel resize via mouse drag.
* @returns Resize state and mouse event handler.
*/
export function useLogDetailsResize() {
const setPanelWidth = useLogDetailsUIStore((state) => state.setPanelWidth)
const setIsResizing = useLogDetailsUIStore((state) => state.setIsResizing)
const [isResizing, setLocalIsResizing] = useState(false)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setLocalIsResizing(true)
setIsResizing(true)
},
[setIsResizing]
)
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
// Calculate new width from right edge of window
const newWidth = window.innerWidth - e.clientX
const clampedWidth = Math.max(
MIN_LOG_DETAILS_WIDTH,
Math.min(newWidth, MAX_LOG_DETAILS_WIDTH)
)
setPanelWidth(clampedWidth)
}
const handleMouseUp = () => {
setLocalIsResizing(false)
setIsResizing(false)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, setPanelWidth, setIsResizing])
return {
isResizing,
handleMouseDown,
}
}

View File

@@ -107,6 +107,33 @@ export default function Logs() {
}
}, [debouncedSearchQuery, setStoreSearchQuery])
// Sync selected log with refreshed data from logs list
useEffect(() => {
if (!selectedLog?.id || logs.length === 0) return
const updatedLog = logs.find((l) => l.id === selectedLog.id)
if (updatedLog) {
// Update selectedLog with fresh data from the list
setSelectedLog(updatedLog)
// Update index in case position changed
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
if (newIndex !== selectedLogIndex) {
setSelectedLogIndex(newIndex)
}
}
}, [logs, selectedLog?.id, selectedLogIndex])
// Refetch log details during live mode
useEffect(() => {
if (!isLive || !selectedLog?.id) return
const interval = setInterval(() => {
logDetailQuery.refetch()
}, 5000)
return () => clearInterval(interval)
}, [isLive, selectedLog?.id, logDetailQuery])
const handleLogClick = (log: WorkflowLog) => {
// If clicking on the same log that's already selected and sidebar is open, close it
if (selectedLog?.id === log.id && isSidebarOpen) {

View File

@@ -836,8 +836,7 @@ try {
<>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent
size='full'
className='h-[80vh]'
size='xl'
onKeyDown={(e) => {
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
e.preventDefault()

View File

@@ -0,0 +1,46 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Width constraints for the log details panel.
*/
export const MIN_LOG_DETAILS_WIDTH = 340
export const MAX_LOG_DETAILS_WIDTH = 700
export const DEFAULT_LOG_DETAILS_WIDTH = 340
/**
* Log details UI state persisted across sessions.
*/
interface LogDetailsUIState {
panelWidth: number
setPanelWidth: (width: number) => void
isResizing: boolean
setIsResizing: (isResizing: boolean) => void
}
export const useLogDetailsUIStore = create<LogDetailsUIState>()(
persist(
(set) => ({
panelWidth: DEFAULT_LOG_DETAILS_WIDTH,
/**
* Updates the log details panel width, enforcing min/max constraints.
* @param width - Desired width in pixels for the panel.
*/
setPanelWidth: (width) => {
const clampedWidth = Math.max(MIN_LOG_DETAILS_WIDTH, Math.min(width, MAX_LOG_DETAILS_WIDTH))
set({ panelWidth: clampedWidth })
},
isResizing: false,
/**
* Updates the resize state flag.
* @param isResizing - True while the panel is being resized via mouse drag.
*/
setIsResizing: (isResizing) => {
set({ isResizing })
},
}),
{
name: 'log-details-ui-state',
}
)
)