From 0cb41592edf6292c671ae21ce496c92b51048f7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Apr 2026 13:40:10 -0700 Subject: [PATCH 01/28] improvement(trace-spans): rewrite trace span pipeline with per-iteration enrichment Unify tool calls under span.children, capture dual-clock timing, and surface per-iteration model content (assistant text, thinking, tool calls, finish reason, tokens, cost, ttft, provider, errors) across all 12 LLM providers. UI renders the new fields on model child spans; old logs degrade gracefully since every field is optional. Co-Authored-By: Claude Opus 4.7 --- .../components/trace-spans/trace-spans.tsx | 295 ++++-- apps/sim/executor/execution/block-executor.ts | 113 ++- apps/sim/executor/types.ts | 121 ++- .../copilot/tools/server/jobs/get-job-logs.ts | 64 +- .../server/workflow/get-workflow-logs.ts | 2 +- apps/sim/lib/core/telemetry.ts | 3 + apps/sim/lib/logs/execution/logger.ts | 11 - .../trace-spans/iteration-grouping.ts | 323 +++++++ .../execution/trace-spans/span-factory.ts | 382 ++++++++ .../execution/trace-spans/trace-spans.test.ts | 144 ++- .../logs/execution/trace-spans/trace-spans.ts | 865 ++---------------- apps/sim/lib/logs/types.ts | 67 +- apps/sim/lib/tokenization/streaming.ts | 17 +- apps/sim/lib/tokenization/utils.ts | 7 +- apps/sim/providers/anthropic/core.ts | 129 ++- apps/sim/providers/azure-openai/index.ts | 21 +- apps/sim/providers/bedrock/index.ts | 78 +- apps/sim/providers/cerebras/index.ts | 38 +- apps/sim/providers/deepseek/index.ts | 25 +- apps/sim/providers/fireworks/index.ts | 37 +- apps/sim/providers/gemini/core.ts | 101 +- apps/sim/providers/groq/index.ts | 25 +- apps/sim/providers/mistral/index.ts | 30 +- apps/sim/providers/ollama/index.ts | 30 +- apps/sim/providers/openai/core.ts | 119 ++- apps/sim/providers/openai/utils.ts | 23 + apps/sim/providers/openrouter/index.ts | 37 +- apps/sim/providers/trace-enrichment.ts | 221 +++++ apps/sim/providers/types.ts | 15 +- apps/sim/providers/vllm/index.ts | 30 +- apps/sim/providers/xai/index.ts | 30 +- apps/sim/stores/logs/filters/types.ts | 61 +- 32 files changed, 2382 insertions(+), 1082 deletions(-) create mode 100644 apps/sim/lib/logs/execution/trace-spans/iteration-grouping.ts create mode 100644 apps/sim/lib/logs/execution/trace-spans/span-factory.ts create mode 100644 apps/sim/providers/trace-enrichment.ts diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index a6c740a46fa..616d33f99c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -20,11 +20,11 @@ import { import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' +import type { TraceSpan } from '@/lib/logs/types' 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' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' -import type { TraceSpan } from '@/stores/logs/filters/types' interface TraceSpansProps { traceSpans?: TraceSpan[] @@ -58,6 +58,86 @@ function useSetToggle() { ) } +/** + * Formats a token count with locale-aware thousands separators. + * Returns `undefined` for missing or non-positive counts so callers can + * filter them out before rendering. + */ +function formatTokenCount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + return value.toLocaleString('en-US') +} + +/** + * Builds a compact, dot-separated token summary for a span: + * `"1,234 in · 567 out · 1,801 total"` with cache/reasoning appended when + * present. Returns `undefined` when the span has no meaningful token data. + */ +function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined { + if (!tokens) return undefined + const parts: string[] = [] + const input = formatTokenCount(tokens.input) + const output = formatTokenCount(tokens.output) + const total = formatTokenCount(tokens.total) + const cacheRead = formatTokenCount(tokens.cacheRead) + const cacheWrite = formatTokenCount(tokens.cacheWrite) + const reasoning = formatTokenCount(tokens.reasoning) + if (input) parts.push(`${input} in`) + if (cacheRead) parts.push(`${cacheRead} cached`) + if (cacheWrite) parts.push(`${cacheWrite} cache write`) + if (output) parts.push(`${output} out`) + if (reasoning) parts.push(`${reasoning} reasoning`) + if (total) parts.push(`${total} total`) + return parts.length > 0 ? parts.join(' · ') : undefined +} + +/** + * Formats a USD cost value for display. Shows `<$0.0001` for non-zero sub-cent + * amounts so the user sees it was counted. + */ +function formatCostAmount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + if (value < 0.0001) return '<$0.0001' + return `$${value.toFixed(4)}` +} + +/** + * Builds a compact cost summary: `"$0.0023 · $0.0001 in · $0.0022 out"`. + * Falls back to whichever parts are present. + */ +function formatCostSummary(cost: TraceSpan['cost']): string | undefined { + if (!cost) return undefined + const parts: string[] = [] + const total = formatCostAmount(cost.total) + const input = formatCostAmount(cost.input) + const output = formatCostAmount(cost.output) + if (total) parts.push(total) + if (input) parts.push(`${input} in`) + if (output) parts.push(`${output} out`) + return parts.length > 0 ? parts.join(' · ') : undefined +} + +/** + * Derives tokens-per-second from output tokens over segment duration. + * Returns `undefined` when inputs are missing or non-positive. + */ +function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined { + if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined + if (!(durationMs > 0)) return undefined + const tps = Math.round(outputTokens / (durationMs / 1000)) + if (!(tps > 0)) return undefined + return `${tps.toLocaleString('en-US')} tok/s` +} + +/** + * Formats time-to-first-token. Uses `ms` below 1000, `s` above. + */ +function formatTtft(ms: number | undefined): string | undefined { + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + /** * Parses a time value to milliseconds */ @@ -185,7 +265,7 @@ function ProgressBar({ const computeSegment = (s: TraceSpan) => { const startMs = new Date(s.startTime).getTime() const endMs = new Date(s.endTime).getTime() - const duration = endMs - startMs + const duration = s.duration || endMs - startMs const startPercent = totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0 const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0 @@ -238,7 +318,7 @@ function InputOutputSection({ data: unknown isError: boolean spanId: string - sectionType: 'input' | 'output' + sectionType: 'input' | 'output' | 'thinking' | 'modelToolCalls' | 'errorMessage' expandedSections: Set onToggle: (section: string) => void }) { @@ -268,6 +348,7 @@ function InputOutputSection({ const jsonString = useMemo(() => { if (!data) return '' + if (typeof data === 'string') return data return JSON.stringify(data, null, 2) }, [data]) @@ -513,63 +594,52 @@ const TraceSpanNode = memo(function TraceSpanNode({ const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name) - // Build all children including tool calls - const allChildren = useMemo(() => { - const children: TraceSpan[] = [] - - // Add tool calls as child spans - if (span.toolCalls && span.toolCalls.length > 0) { - span.toolCalls.forEach((toolCall, index) => { - const toolStartTime = toolCall.startTime - ? new Date(toolCall.startTime).getTime() - : spanStartTime - const toolEndTime = toolCall.endTime - ? new Date(toolCall.endTime).getTime() - : toolStartTime + (toolCall.duration || 0) - - children.push({ - id: `${spanId}-tool-${index}`, - name: toolCall.name, + const displayChildren = useMemo(() => { + const kids: TraceSpan[] = span.children?.length + ? [...span.children] + : (span.toolCalls ?? []).map((tc, i) => ({ + id: `${spanId}-tool-${i}`, + name: tc.name, type: 'tool', - duration: toolCall.duration || toolEndTime - toolStartTime, - startTime: new Date(toolStartTime).toISOString(), - endTime: new Date(toolEndTime).toISOString(), - status: toolCall.error ? ('error' as const) : ('success' as const), - input: toolCall.input, - output: toolCall.error - ? { error: toolCall.error, ...(toolCall.output || {}) } - : toolCall.output, - } as TraceSpan) - }) - } - - // Add regular children - if (span.children && span.children.length > 0) { - children.push(...span.children) - } + duration: tc.duration || 0, + startTime: tc.startTime ?? span.startTime, + endTime: tc.endTime ?? span.endTime, + status: tc.error ? ('error' as const) : ('success' as const), + input: tc.input, + output: tc.error ? { error: tc.error, ...(tc.output ?? {}) } : tc.output, + })) - // Sort by start time - return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) - }, [span, spanId, spanStartTime]) + kids.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) - // Hide empty model timing segments for agents without tool calls - const filteredChildren = useMemo(() => { const isAgent = span.type?.toLowerCase() === 'agent' - const hasToolCalls = - (span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool') - - if (isAgent && !hasToolCalls) { - return allChildren.filter((c) => c.type?.toLowerCase() !== 'model') + const hasToolCall = kids.some((c) => c.type?.toLowerCase() === 'tool') + if (isAgent && !hasToolCall) { + return kids.filter((c) => c.type?.toLowerCase() !== 'model') } - return allChildren - }, [allChildren, span.type, span.toolCalls]) + return kids + }, [span, spanId]) - const hasChildren = filteredChildren.length > 0 + const hasChildren = displayChildren.length > 0 const isExpanded = isRootWorkflow || expandedNodes.has(spanId) const isToggleable = !isRootWorkflow const hasInput = Boolean(span.input) const hasOutput = Boolean(span.output) + const hasThinking = Boolean(span.thinking) + const hasModelToolCalls = Boolean(span.modelToolCalls && span.modelToolCalls.length > 0) + const hasFinishReason = Boolean(span.finishReason) + const tokensSummary = formatTokensSummary(span.tokens) + const hasTokens = Boolean(tokensSummary) + const costSummary = formatCostSummary(span.cost) + const hasCost = Boolean(costSummary) + const isModelSpan = span.type?.toLowerCase() === 'model' + const tpsSummary = isModelSpan ? formatTps(span.tokens?.output, duration) : undefined + const hasTps = Boolean(tpsSummary) + const ttftSummary = formatTtft(span.ttft) + const hasTtft = Boolean(ttftSummary) + const hasProvider = Boolean(span.provider) + const hasErrorType = Boolean(span.errorType) + const hasErrorMessage = Boolean(span.errorMessage) // For progress bar - show child segments for workflow/iteration types const lowerType = span.type?.toLowerCase() || '' @@ -641,7 +711,18 @@ const TraceSpanNode = memo(function TraceSpanNode({ /> {/* Input/Output Sections */} - {(hasInput || hasOutput) && ( + {(hasInput || + hasOutput || + hasThinking || + hasModelToolCalls || + hasFinishReason || + hasTokens || + hasCost || + hasTps || + hasTtft || + hasProvider || + hasErrorType || + hasErrorMessage) && (
{hasInput && ( )} + + {hasThinking && ( + <> + {(hasInput || hasOutput) && ( +
+ )} + + + )} + + {hasModelToolCalls && ( + <> + {(hasInput || hasOutput || hasThinking) && ( +
+ )} + + + )} + + {hasErrorMessage && ( + <> + {(hasInput || hasOutput || hasThinking || hasModelToolCalls) && ( +
+ )} + + + )} + + {hasErrorType && ( +
+ Error type + + {span.errorType} + +
+ )} + + {hasFinishReason && ( +
+ Finish reason + {span.finishReason} +
+ )} + + {hasProvider && ( +
+ Provider + + {span.provider} + +
+ )} + + {hasTtft && ( +
+ TTFT + + {ttftSummary} + +
+ )} + + {hasTokens && ( +
+ Tokens + + {tokensSummary} + +
+ )} + + {hasTps && ( +
+ Throughput + + {tpsSummary} + +
+ )} + + {hasCost && ( +
+ Cost + + {costSummary} + +
+ )}
)} {/* Nested Children */} {hasChildren && (
- {filteredChildren.map((child, index) => ( + {displayChildren.map((child, index) => (
= {} const nodeMetadata = { @@ -169,10 +174,11 @@ export class BlockExecutor { })) as NormalizedBlockOutput } + const endedAt = new Date().toISOString() const duration = performance.now() - startTime if (blockLog) { - blockLog.endedAt = new Date().toISOString() + blockLog.endedAt = endedAt blockLog.durationMs = duration blockLog.success = true blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block }) @@ -191,7 +197,7 @@ export class BlockExecutor { const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block, }) - await this.callOnBlockComplete( + this.fireBlockCompleteCallback( ctx, node, block, @@ -249,6 +255,7 @@ export class BlockExecutor { isSentinel: boolean, phase: 'input_resolution' | 'execution' ): Promise { + const endedAt = new Date().toISOString() const duration = performance.now() - startTime const errorMessage = normalizeError(error) const hasResolvedInputs = @@ -273,7 +280,7 @@ export class BlockExecutor { this.state.setBlockOutput(node.id, errorOutput, duration) if (blockLog) { - blockLog.endedAt = new Date().toISOString() + blockLog.endedAt = endedAt blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage @@ -299,7 +306,7 @@ export class BlockExecutor { ? error.childWorkflowInstanceId : undefined const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) - await this.callOnBlockComplete( + this.fireBlockCompleteCallback( ctx, node, block, @@ -351,7 +358,8 @@ export class BlockExecutor { ctx: ExecutionContext, blockId: string, block: SerializedBlock, - node: DAGNode + node: DAGNode, + startedAt: string ): BlockLog { let blockName = block.metadata?.name ?? blockId let loopId: string | undefined @@ -384,7 +392,7 @@ export class BlockExecutor { blockId, blockName, blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE, - startedAt: new Date().toISOString(), + startedAt, executionOrder: getNextExecutionOrder(ctx), endedAt: '', durationMs: 0, @@ -451,39 +459,47 @@ export class BlockExecutor { return redactApiKeys(result) } - private async callOnBlockStart( + /** + * Fires the `onBlockStart` progress callback without blocking block execution. + * Any error is logged and swallowed so callback I/O never stalls the critical path. + */ + private fireBlockStartCallback( ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, executionOrder: number - ): Promise { + ): void { + if (!this.contextExtensions.onBlockStart) return + const blockId = node.metadata?.originalBlockId ?? node.id const blockName = block.metadata?.name ?? blockId const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE - const iterationContext = getIterationContext(ctx, node?.metadata) - if (this.contextExtensions.onBlockStart) { - try { - await this.contextExtensions.onBlockStart( - blockId, - blockName, - blockType, - executionOrder, - iterationContext, - ctx.childWorkflowContext - ) - } catch (error) { + void this.contextExtensions + .onBlockStart( + blockId, + blockName, + blockType, + executionOrder, + iterationContext, + ctx.childWorkflowContext + ) + .catch((error) => { this.execLogger.warn('Block start callback failed', { blockId, blockType, error: toError(error).message, }) - } - } + }) } - private async callOnBlockComplete( + /** + * Fires the `onBlockComplete` progress callback without blocking subsequent blocks. + * The callback typically performs DB writes for progress markers — awaiting it would + * add latency between blocks and skew wall-clock timing in the trace view. + */ + private fireBlockCompleteCallback( ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -494,39 +510,38 @@ export class BlockExecutor { executionOrder: number, endedAt: string, childWorkflowInstanceId?: string - ): Promise { + ): void { + if (!this.contextExtensions.onBlockComplete) return + const blockId = node.metadata?.originalBlockId ?? node.id const blockName = block.metadata?.name ?? blockId const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE - const iterationContext = getIterationContext(ctx, node?.metadata) - if (this.contextExtensions.onBlockComplete) { - try { - await this.contextExtensions.onBlockComplete( - blockId, - blockName, - blockType, - { - input, - output, - executionTime: duration, - startedAt, - executionOrder, - endedAt, - childWorkflowInstanceId, - }, - iterationContext, - ctx.childWorkflowContext - ) - } catch (error) { + void this.contextExtensions + .onBlockComplete( + blockId, + blockName, + blockType, + { + input, + output, + executionTime: duration, + startedAt, + executionOrder, + endedAt, + childWorkflowInstanceId, + }, + iterationContext, + ctx.childWorkflowContext + ) + .catch((error) => { this.execLogger.warn('Block completion callback failed', { blockId, blockType, error: toError(error).message, }) - } - } + }) } private preparePauseResumeSelfReference( diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index b8b0d4cfa0d..7d844257d1e 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -74,19 +74,118 @@ export interface SerializedSnapshot { triggerIds: string[] } +/** + * Identifies a tool call emitted by a model iteration. Matches the + * `tool_call.id` convention used by OpenAI, Anthropic, and the OTel GenAI + * spec so tool segments can be correlated back to the iteration that issued + * them. + */ +export interface IterationToolCall { + id: string + name: string + arguments: Record | string +} + +/** + * A single phase of provider execution (model call or tool invocation). + * + * Providers emit these per iteration. Model segments carry the assistant's + * output for that iteration (text, thinking, tool_calls, tokens, finish + * reason) so the trace reveals *why* each tool was invoked — not just that + * it was. All content fields are optional; providers fill in what they have. + */ +export interface ProviderTimingSegment { + type: 'model' | 'tool' + name?: string + startTime: number + endTime: number + duration: number + assistantContent?: string + thinkingContent?: string + toolCalls?: IterationToolCall[] + toolCallId?: string + finishReason?: string + tokens?: BlockTokens + /** Cost for this segment in USD, derived from tokens + model pricing. */ + cost?: { input?: number; output?: number; total?: number } + /** Time-to-first-token in ms (streaming only; first segment typically). */ + ttft?: number + /** Provider system identifier (anthropic, openai, gemini, etc.) — `gen_ai.system`. */ + provider?: string + /** Structured error class (e.g. `rate_limit`, `context_length`). */ + errorType?: string + /** Human-readable error message when this segment failed. */ + errorMessage?: string +} + +/** Timing info reported by an LLM provider for a single block execution. */ +export interface BlockProviderTiming { + startTime: string + endTime: string + duration: number + modelTime?: number + toolsTime?: number + firstResponseTime?: number + iterations?: number + timeSegments?: ProviderTimingSegment[] +} + +/** Cost breakdown from provider usage. */ +export interface BlockCost { + input: number + output: number + total: number + toolCost?: number + pricing?: { + input: number + output: number + cachedInput?: number + updatedAt: string + } +} + +/** Token usage from provider. `prompt`/`completion` are legacy aliases. */ +export interface BlockTokens { + input?: number + output?: number + total?: number + prompt?: number + completion?: number + /** Input tokens served from the provider's prompt cache. */ + cacheRead?: number + /** Input tokens newly written to the provider's prompt cache. */ + cacheWrite?: number + /** Output tokens consumed by reasoning/thinking (o-series, Claude, Gemini). */ + reasoning?: number +} + +/** A single tool invocation recorded by an agent-type block. */ +export interface BlockToolCall { + name: string + duration?: number + startTime?: string + endTime?: string + error?: string + arguments?: Record + input?: Record + result?: Record + output?: Record +} + +/** Normalized tool-call container emitted by providers. */ +export interface BlockToolCalls { + list: BlockToolCall[] + count: number +} + export interface NormalizedBlockOutput { [key: string]: any content?: string model?: string - tokens?: { - input?: number - output?: number - total?: number - } - toolCalls?: { - list: any[] - count: number - } + tokens?: BlockTokens + toolCalls?: BlockToolCalls + providerTiming?: BlockProviderTiming + cost?: BlockCost files?: UserFile[] selectedPath?: { blockId: string @@ -115,8 +214,8 @@ export interface BlockLog { endedAt: string durationMs: number success: boolean - output?: any - input?: any + output?: NormalizedBlockOutput + input?: Record error?: string /** Whether this error was handled by an error handler path (error port) */ errorHandled?: boolean diff --git a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts index 2e222ed4a84..261a4f69065 100644 --- a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts +++ b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { GetJobLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import type { TraceSpan } from '@/lib/logs/types' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('GetJobLogsServerTool') @@ -38,29 +39,68 @@ interface JobLogEntry { tokens?: unknown } -function extractToolCalls(traceSpan: any): ToolCallDetail[] { - if (!traceSpan?.toolCalls || !Array.isArray(traceSpan.toolCalls)) return [] +/** + * Walks the trace-span tree and collects tool invocations from both data shapes: + * - New: `type: 'tool'` spans nested under agent blocks in `children`. + * - Legacy: a `toolCalls` array hanging off the agent span directly (pre-unification). + */ +function collectToolCalls(spans: TraceSpan[] | undefined): ToolCallDetail[] { + if (!spans?.length) return [] + const collected: ToolCallDetail[] = [] + + const visit = (span: TraceSpan) => { + if (span.type === 'tool') { + const output = span.output as { result?: unknown } | undefined + collected.push({ + name: span.name || 'unknown', + input: span.input ?? {}, + output: output?.result ?? span.output, + error: span.status === 'error' ? errorMessageFromSpan(span) : undefined, + duration: span.duration || 0, + }) + return + } + + if (span.toolCalls?.length) { + for (const tc of span.toolCalls) { + collected.push({ + name: tc.name || 'unknown', + input: tc.input ?? {}, + output: tc.output ?? undefined, + error: tc.error || undefined, + duration: tc.duration || 0, + }) + } + } + + if (span.children?.length) { + for (const child of span.children) visit(child) + } + } + + for (const span of spans) visit(span) + return collected +} - return traceSpan.toolCalls.map((tc: any) => ({ - name: tc.name || 'unknown', - input: tc.input || tc.arguments || {}, - output: tc.output || tc.result || undefined, - error: tc.error || undefined, - duration: tc.duration || 0, - })) +function errorMessageFromSpan(span: TraceSpan): string | undefined { + const out = span.output as { error?: unknown } | undefined + if (typeof out?.error === 'string') return out.error + return undefined } -function extractOutputAndError(executionData: any): { +function extractOutputAndError( + executionData: { traceSpans?: TraceSpan[] } & Record +): { output: unknown error: string | undefined toolCalls: ToolCallDetail[] cost: unknown tokens: unknown } { - const traceSpans = executionData?.traceSpans || [] + const traceSpans = executionData?.traceSpans ?? [] const mainSpan = traceSpans[0] - const toolCalls = mainSpan ? extractToolCalls(mainSpan) : [] + const toolCalls = collectToolCalls(traceSpans) const output = mainSpan?.output || executionData?.finalOutput || undefined const cost = mainSpan?.cost || executionData?.cost || undefined const tokens = mainSpan?.tokens || undefined diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts index 3ab0cc2d573..0daf2aa07d0 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts @@ -5,7 +5,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { GetWorkflowLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import type { TraceSpan } from '@/stores/logs/filters/types' +import type { TraceSpan } from '@/lib/logs/types' const logger = createLogger('GetWorkflowLogsServerTool') diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index 9dd1f409580..34af72809f1 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -100,6 +100,9 @@ const BLOCK_TYPE_MAPPING: Record< } if (span.tokens) { + // `TraceSpan.tokens` is typed as an object, but older persisted logs + // stored it as a bare number (total). Keep the numeric branch for those + // legacy rows. if (typeof span.tokens === 'number') { attrs[GenAIAttributes.USAGE_TOTAL_TOKENS] = span.tokens } else { diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index d538ab738ad..707673b3431 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -47,17 +47,6 @@ const TRIGGER_COUNTER_MAP: Record = { a2a: { key: 'totalA2aExecutions', column: 'total_a2a_executions' }, } as const -export interface ToolCall { - name: string - duration: number // in milliseconds - startTime: string // ISO timestamp - endTime: string // ISO timestamp - status: 'success' | 'error' - input?: Record - output?: Record - error?: string -} - const logger = createLogger('ExecutionLogger') function countTraceSpans(traceSpans?: TraceSpan[]): number { diff --git a/apps/sim/lib/logs/execution/trace-spans/iteration-grouping.ts b/apps/sim/lib/logs/execution/trace-spans/iteration-grouping.ts new file mode 100644 index 00000000000..1515ef7c42e --- /dev/null +++ b/apps/sim/lib/logs/execution/trace-spans/iteration-grouping.ts @@ -0,0 +1,323 @@ +import { createLogger } from '@sim/logger' +import type { TraceSpan } from '@/lib/logs/types' +import { stripCloneSuffixes } from '@/executor/utils/subflow-utils' + +const logger = createLogger('IterationGrouping') + +/** Counter state for generating sequential container names. */ +interface ContainerNameCounters { + loopNumbers: Map + parallelNumbers: Map + loopCounter: number + parallelCounter: number +} + +/** + * Builds a container-level TraceSpan (iteration wrapper or top-level container) + * from its source spans and resolved children. + */ +function buildContainerSpan(opts: { + id: string + name: string + type: string + sourceSpans: TraceSpan[] + children: TraceSpan[] +}): TraceSpan { + const startTimes = opts.sourceSpans.map((s) => new Date(s.startTime).getTime()) + const endTimes = opts.sourceSpans.map((s) => new Date(s.endTime).getTime()) + + // Guard against empty sourceSpans — Math.min/max of empty array returns ±Infinity + // which produces NaN durations and invalid Dates downstream. + const nowMs = Date.now() + const earliestStart = startTimes.length > 0 ? Math.min(...startTimes) : nowMs + const latestEnd = endTimes.length > 0 ? Math.max(...endTimes) : nowMs + + const hasErrors = opts.sourceSpans.some((s) => s.status === 'error') + const allErrorsHandled = + hasErrors && opts.children.every((s) => s.status !== 'error' || s.errorHandled) + + return { + id: opts.id, + name: opts.name, + type: opts.type, + duration: Math.max(0, latestEnd - earliestStart), + startTime: new Date(earliestStart).toISOString(), + endTime: new Date(latestEnd).toISOString(), + status: hasErrors ? 'error' : 'success', + ...(allErrorsHandled && { errorHandled: true }), + children: opts.children, + } +} + +/** + * Resolves a container name from normal (non-iteration) spans or assigns a sequential number. + * Strips clone suffixes so all clones of the same container share one name/number. + */ +function resolveContainerName( + containerId: string, + containerType: 'parallel' | 'loop', + normalSpans: TraceSpan[], + counters: ContainerNameCounters +): string { + const originalId = stripCloneSuffixes(containerId) + + const matchingBlock = normalSpans.find( + (s) => s.blockId === originalId && s.type === containerType + ) + if (matchingBlock?.name) return matchingBlock.name + + if (containerType === 'parallel') { + if (!counters.parallelNumbers.has(originalId)) { + counters.parallelNumbers.set(originalId, counters.parallelCounter++) + } + return `Parallel ${counters.parallelNumbers.get(originalId)}` + } + if (!counters.loopNumbers.has(originalId)) { + counters.loopNumbers.set(originalId, counters.loopCounter++) + } + return `Loop ${counters.loopNumbers.get(originalId)}` +} + +/** + * Classifies a span's immediate container ID and type from its metadata. + * Returns undefined for non-iteration spans. + */ +function classifySpanContainer( + span: TraceSpan +): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { + if (span.parallelId) { + return { containerId: span.parallelId, containerType: 'parallel' } + } + if (span.loopId) { + return { containerId: span.loopId, containerType: 'loop' } + } + if (span.blockId?.includes('_parallel_')) { + const match = span.blockId.match(/_parallel_([^_]+)_iteration_/) + if (match) { + return { containerId: match[1], containerType: 'parallel' } + } + } + return undefined +} + +/** + * Finds the outermost container for a span. For nested spans, this is parentIterations[0]. + * For flat spans, this is the span's own immediate container. + */ +function getOutermostContainer( + span: TraceSpan +): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { + if (span.parentIterations && span.parentIterations.length > 0) { + const outermost = span.parentIterations[0] + return { + containerId: outermost.iterationContainerId, + containerType: outermost.iterationType as 'parallel' | 'loop', + } + } + return classifySpanContainer(span) +} + +/** + * Builds the iteration-level hierarchy for a container, recursively nesting + * any deeper subflows. Works with both: + * - Direct spans (spans whose immediate container matches) + * - Nested spans (spans with parentIterations pointing through this container) + */ +function buildContainerChildren( + containerType: 'parallel' | 'loop', + containerId: string, + spans: TraceSpan[], + normalSpans: TraceSpan[], + counters: ContainerNameCounters +): TraceSpan[] { + const iterationType = containerType === 'parallel' ? 'parallel-iteration' : 'loop-iteration' + + const iterationGroups = new Map() + + for (const span of spans) { + let iterIdx: number | undefined + + if ( + span.parentIterations && + span.parentIterations.length > 0 && + span.parentIterations[0].iterationContainerId === containerId + ) { + iterIdx = span.parentIterations[0].iterationCurrent + } else { + iterIdx = span.iterationIndex + } + + if (iterIdx === undefined) { + logger.warn('Skipping iteration span without iterationIndex', { + spanId: span.id, + blockId: span.blockId, + containerId, + }) + continue + } + + if (!iterationGroups.has(iterIdx)) iterationGroups.set(iterIdx, []) + iterationGroups.get(iterIdx)!.push(span) + } + + const iterationChildren: TraceSpan[] = [] + const sortedIterations = Array.from(iterationGroups.entries()).sort(([a], [b]) => a - b) + + for (const [iterationIndex, iterSpans] of sortedIterations) { + const directLeaves: TraceSpan[] = [] + const deeperSpans: TraceSpan[] = [] + + for (const span of iterSpans) { + if ( + span.parentIterations && + span.parentIterations.length > 0 && + span.parentIterations[0].iterationContainerId === containerId + ) { + deeperSpans.push({ + ...span, + parentIterations: span.parentIterations.slice(1), + }) + } else { + directLeaves.push({ + ...span, + name: span.name.replace(/ \(iteration \d+\)$/, ''), + }) + } + } + + const nestedResult = groupIterationBlocksRecursive( + [...directLeaves, ...deeperSpans], + normalSpans, + counters + ) + + iterationChildren.push( + buildContainerSpan({ + id: `${containerId}-iteration-${iterationIndex}`, + name: `Iteration ${iterationIndex}`, + type: iterationType, + sourceSpans: iterSpans, + children: nestedResult, + }) + ) + } + + return iterationChildren +} + +/** + * Core recursive algorithm for grouping iteration blocks. + * + * Handles two cases: + * 1. **Flat** (backward compat): spans have loopId/parallelId + iterationIndex but no + * parentIterations. Grouped by immediate container -> iteration -> leaf. + * 2. **Nested** (new): spans have parentIterations chains. The outermost ancestor in the + * chain determines the top-level container. Iteration spans are peeled one level at a + * time and recursed. + */ +function groupIterationBlocksRecursive( + spans: TraceSpan[], + normalSpans: TraceSpan[], + counters: ContainerNameCounters +): TraceSpan[] { + const result: TraceSpan[] = [] + const iterationSpans: TraceSpan[] = [] + const nonIterationSpans: TraceSpan[] = [] + + for (const span of spans) { + if ( + span.name.match(/^(.+) \(iteration (\d+)\)$/) || + (span.parentIterations && span.parentIterations.length > 0) + ) { + iterationSpans.push(span) + } else { + nonIterationSpans.push(span) + } + } + + const containerIdsWithIterations = new Set() + for (const span of iterationSpans) { + const outermost = getOutermostContainer(span) + if (outermost) containerIdsWithIterations.add(outermost.containerId) + } + + const nonContainerSpans = nonIterationSpans.filter( + (span) => + (span.type !== 'parallel' && span.type !== 'loop') || + span.status === 'error' || + (span.blockId && !containerIdsWithIterations.has(span.blockId)) + ) + + if (iterationSpans.length === 0) { + result.push(...nonContainerSpans) + result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + return result + } + + const containerGroups = new Map< + string, + { type: 'parallel' | 'loop'; containerId: string; containerName: string; spans: TraceSpan[] } + >() + + for (const span of iterationSpans) { + const outermost = getOutermostContainer(span) + if (!outermost) continue + + const { containerId, containerType } = outermost + const groupKey = `${containerType}_${containerId}` + + if (!containerGroups.has(groupKey)) { + const containerName = resolveContainerName(containerId, containerType, normalSpans, counters) + containerGroups.set(groupKey, { + type: containerType, + containerId, + containerName, + spans: [], + }) + } + containerGroups.get(groupKey)!.spans.push(span) + } + + for (const [, group] of containerGroups) { + const { type, containerId, containerName, spans: containerSpans } = group + + const iterationChildren = buildContainerChildren( + type, + containerId, + containerSpans, + normalSpans, + counters + ) + + result.push( + buildContainerSpan({ + id: `${type === 'parallel' ? 'parallel' : 'loop'}-execution-${containerId}`, + name: containerName, + type, + sourceSpans: containerSpans, + children: iterationChildren, + }) + ) + } + + result.push(...nonContainerSpans) + result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + + return result +} + +/** + * Groups iteration-based blocks (parallel and loop) by organizing their iteration spans + * into a hierarchical structure with proper parent-child relationships. + * Supports recursive nesting via parentIterations (e.g., parallel-in-parallel, loop-in-loop). + */ +export function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] { + const normalSpans = spans.filter((s) => !s.name.match(/^(.+) \(iteration (\d+)\)$/)) + const counters: ContainerNameCounters = { + loopNumbers: new Map(), + parallelNumbers: new Map(), + loopCounter: 1, + parallelCounter: 1, + } + return groupIterationBlocksRecursive(spans, normalSpans, counters) +} diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts new file mode 100644 index 00000000000..ede01150f78 --- /dev/null +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -0,0 +1,382 @@ +import { createLogger } from '@sim/logger' +import type { ProviderTiming, TraceSpan } from '@/lib/logs/types' +import { + isConditionBlockType, + isWorkflowBlockType, + stripCustomToolPrefix, +} from '@/executor/constants' +import type { + BlockLog, + BlockToolCall, + NormalizedBlockOutput, + ProviderTimingSegment, +} from '@/executor/types' + +const logger = createLogger('SpanFactory') + +const STREAMING_SEGMENT_NAME = 'Streaming response' + +/** A BlockLog that has already passed the id/type validity check. */ +type ValidBlockLog = BlockLog & { blockType: string } + +/** + * Creates a TraceSpan from a BlockLog. Returns null for invalid logs. + * + * Children are unified under `span.children` regardless of source: + * - Provider `timeSegments` become model/tool child spans with tool I/O merged in + * - `output.toolCalls` (no segments) become tool child spans + * - Child workflow spans are flattened into children + */ +export function createSpanFromLog(log: BlockLog): TraceSpan | null { + if (!log.blockId || !log.blockType) return null + const validLog = log as ValidBlockLog + + const span = createBaseSpan(validLog) + + if (!isConditionBlockType(validLog.blockType)) { + enrichWithProviderMetadata(span, validLog) + + if (!isWorkflowBlockType(validLog.blockType)) { + const segments = validLog.output?.providerTiming?.timeSegments + span.children = segments + ? buildChildrenFromTimeSegments(span, validLog, segments) + : buildChildrenFromToolCalls(span, validLog) + } + } + + if (isWorkflowBlockType(validLog.blockType)) { + attachChildWorkflowSpans(span, validLog) + } + + return span +} + +/** Creates the base span with id, name, type, timing, status, and metadata. */ +function createBaseSpan(log: ValidBlockLog): TraceSpan { + const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` + const output = extractDisplayOutput(log) + const childIds = extractChildWorkflowIds(log.output) + + return { + id: spanId, + name: log.blockName ?? log.blockId, + type: log.blockType, + duration: log.durationMs, + startTime: log.startedAt, + endTime: log.endedAt, + status: log.error ? 'error' : 'success', + children: [], + blockId: log.blockId, + input: log.input, + output, + ...(childIds ?? {}), + ...(log.errorHandled && { errorHandled: true }), + ...(log.loopId && { loopId: log.loopId }), + ...(log.parallelId && { parallelId: log.parallelId }), + ...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }), + ...(log.parentIterations?.length && { parentIterations: log.parentIterations }), + } +} + +/** + * Strips internal fields from the block output for display and merges + * the block-level error into output so the UI renders it alongside data. + */ +function extractDisplayOutput(log: ValidBlockLog): Record { + const { childWorkflowSnapshotId, childWorkflowId, ...rest } = log.output ?? {} + return log.error ? { ...rest, error: log.error } : rest +} + +/** Pulls child-workflow identifiers off the output so they can live on the span. */ +function extractChildWorkflowIds( + output: NormalizedBlockOutput | undefined +): { childWorkflowSnapshotId?: string; childWorkflowId?: string } | undefined { + if (!output) return undefined + const ids: { childWorkflowSnapshotId?: string; childWorkflowId?: string } = {} + if (typeof output.childWorkflowSnapshotId === 'string') { + ids.childWorkflowSnapshotId = output.childWorkflowSnapshotId + } + if (typeof output.childWorkflowId === 'string') { + ids.childWorkflowId = output.childWorkflowId + } + return ids.childWorkflowSnapshotId || ids.childWorkflowId ? ids : undefined +} + +/** Enriches a span with provider timing, cost, tokens, and model from block output. */ +function enrichWithProviderMetadata(span: TraceSpan, log: ValidBlockLog): void { + const output = log.output + if (!output) return + + if (output.providerTiming) { + const pt = output.providerTiming + const timing: ProviderTiming = { + duration: pt.duration, + startTime: pt.startTime, + endTime: pt.endTime, + segments: pt.timeSegments ?? [], + } + span.providerTiming = timing + } + + if (output.cost) { + const { input, output: out, total } = output.cost + span.cost = { input, output: out, total } + } + + if (output.tokens) { + const t = output.tokens + const input = + typeof t.input === 'number' ? t.input : typeof t.prompt === 'number' ? t.prompt : undefined + const outputTokens = + typeof t.output === 'number' + ? t.output + : typeof t.completion === 'number' + ? t.completion + : undefined + const totalExplicit = typeof t.total === 'number' ? t.total : undefined + const total = + totalExplicit ?? + (input !== undefined || outputTokens !== undefined + ? (input ?? 0) + (outputTokens ?? 0) + : undefined) + span.tokens = { + ...(input !== undefined && { input }), + ...(outputTokens !== undefined && { output: outputTokens }), + ...(total !== undefined && { total }), + } + } + + if (typeof output.model === 'string') { + span.model = output.model + } +} + +/** + * Builds child spans from provider `timeSegments`, matching tool segments to + * their corresponding tool call I/O by name in sequential order. + */ +function buildChildrenFromTimeSegments( + span: TraceSpan, + log: ValidBlockLog, + segments: ProviderTimingSegment[] +): TraceSpan[] { + const toolCallsByName = groupToolCallsByName(resolveToolCallsList(log.output)) + const toolCallIndices = new Map() + + return segments.map((segment, index) => { + const segmentStartTime = new Date(segment.startTime).toISOString() + let segmentEndTime = new Date(segment.endTime).toISOString() + let segmentDuration = segment.duration + + // Streaming segments sometimes close before the block ends; extend the + // trailing streaming segment to the block endTime so the bar fills. + if (segment.name === STREAMING_SEGMENT_NAME && log.endedAt) { + const blockEndMs = new Date(log.endedAt).getTime() + const segmentEndMs = new Date(segment.endTime).getTime() + if (blockEndMs > segmentEndMs) { + segmentEndTime = log.endedAt + segmentDuration = blockEndMs - new Date(segment.startTime).getTime() + } + } + + if (segment.type === 'tool') { + const normalizedName = stripCustomToolPrefix(segment.name ?? '') + const callsForName = toolCallsByName.get(normalizedName) ?? [] + const currentIndex = toolCallIndices.get(normalizedName) ?? 0 + const match = callsForName[currentIndex] + toolCallIndices.set(normalizedName, currentIndex + 1) + + const toolChild: TraceSpan = { + id: `${span.id}-segment-${index}`, + name: normalizedName, + type: 'tool', + duration: segment.duration, + startTime: segmentStartTime, + endTime: segmentEndTime, + status: match?.error || segment.errorMessage ? 'error' : 'success', + input: match?.arguments ?? match?.input, + output: match?.error + ? { error: match.error, ...(match.result ?? match.output ?? {}) } + : (match?.result ?? match?.output), + } + if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId + if (segment.errorType) toolChild.errorType = segment.errorType + if (segment.errorMessage) toolChild.errorMessage = segment.errorMessage + return toolChild + } + + const modelChild: TraceSpan = { + id: `${span.id}-segment-${index}`, + name: segment.name ?? 'Model', + type: 'model', + duration: segmentDuration, + startTime: segmentStartTime, + endTime: segmentEndTime, + status: segment.errorMessage ? 'error' : 'success', + } + + if (segment.assistantContent) { + modelChild.output = { content: segment.assistantContent } + } + if (segment.thinkingContent) { + modelChild.thinking = segment.thinkingContent + } + if (segment.toolCalls && segment.toolCalls.length > 0) { + modelChild.modelToolCalls = segment.toolCalls + } + if (segment.finishReason) { + modelChild.finishReason = segment.finishReason + } + if (segment.tokens) { + modelChild.tokens = segment.tokens + } + if (segment.cost) { + modelChild.cost = segment.cost + } + if (typeof segment.ttft === 'number' && segment.ttft >= 0) { + modelChild.ttft = segment.ttft + } + if (segment.provider) { + modelChild.provider = segment.provider + } + if (segment.errorType) { + modelChild.errorType = segment.errorType + } + if (segment.errorMessage) { + modelChild.errorMessage = segment.errorMessage + } + + return modelChild + }) +} + +/** + * Builds tool-call child spans when the provider did not emit `timeSegments`. + * Each tool call becomes a full TraceSpan of `type: 'tool'`. + */ +function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceSpan[] { + const toolCalls = resolveToolCallsList(log.output) + if (toolCalls.length === 0) return [] + + return toolCalls.map((tc, index) => { + const startTime = tc.startTime ?? log.startedAt + const endTime = tc.endTime ?? log.endedAt + return { + id: `${span.id}-tool-${index}`, + name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'), + type: 'tool', + duration: tc.duration ?? 0, + startTime, + endTime, + status: tc.error ? 'error' : 'success', + input: tc.arguments ?? tc.input, + output: tc.error + ? { error: tc.error, ...(tc.result ?? tc.output ?? {}) } + : (tc.result ?? tc.output), + } + }) +} + +/** Groups tool calls by their stripped name for sequential matching against segments. */ +function groupToolCallsByName(toolCalls: BlockToolCall[]): Map { + const byName = new Map() + for (const tc of toolCalls) { + const name = stripCustomToolPrefix(tc.name ?? '') + const list = byName.get(name) + if (list) list.push(tc) + else byName.set(name, [tc]) + } + return byName +} + +/** + * Resolves the tool calls list from block output. Providers write a normalized + * `{list, count}` container; a legacy streaming path embeds calls under + * `executionData.output.toolCalls`. The `Array.isArray` branches guard against + * persisted logs from before the container shape was normalized, where + * `toolCalls` was stored as a plain array — still observed in older DB rows. + */ +function resolveToolCallsList(output: NormalizedBlockOutput | undefined): BlockToolCall[] { + if (!output) return [] + + const direct = output.toolCalls + if (direct) { + if (Array.isArray(direct)) return direct + if (direct.list) return direct.list + logger.warn('Unexpected toolCalls shape on block output — no list extracted', { + shape: typeof direct, + }) + return [] + } + + const legacy = (output.executionData as { output?: { toolCalls?: unknown } } | undefined)?.output + ?.toolCalls + if (!legacy) return [] + if (Array.isArray(legacy)) return legacy as BlockToolCall[] + if (typeof legacy === 'object' && legacy !== null && 'list' in legacy) { + return ((legacy as { list?: BlockToolCall[] }).list ?? []) as BlockToolCall[] + } + logger.warn('Unexpected legacy executionData.output.toolCalls shape — no list extracted', { + shape: typeof legacy, + }) + return [] +} + +/** Extracts and flattens child workflow trace spans into the parent span's children. */ +function attachChildWorkflowSpans(span: TraceSpan, log: ValidBlockLog): void { + const childTraceSpans = log.childTraceSpans ?? log.output?.childTraceSpans + if (!childTraceSpans?.length) return + + span.children = flattenWorkflowChildren(childTraceSpans) + span.output = stripChildTraceSpansFromOutput(span.output) +} + +/** True when a span is a synthetic workflow wrapper (no blockId). */ +function isSyntheticWorkflowWrapper(span: TraceSpan): boolean { + return span.type === 'workflow' && !span.blockId +} + +/** Reads nested `childTraceSpans` off a span's output, or `[]` if absent. */ +function extractOutputChildren(output: TraceSpan['output']): TraceSpan[] { + const nested = (output as { childTraceSpans?: TraceSpan[] } | undefined)?.childTraceSpans + return Array.isArray(nested) ? nested : [] +} + +/** Returns a copy of `output` with `childTraceSpans` removed, or undefined unchanged. */ +function stripChildTraceSpansFromOutput( + output: TraceSpan['output'] +): TraceSpan['output'] | undefined { + if (!output || !('childTraceSpans' in output)) return output + const { childTraceSpans: _, ...rest } = output as Record + return rest +} + +/** Recursively flattens synthetic workflow wrappers, preserving real block spans. */ +function flattenWorkflowChildren(spans: TraceSpan[]): TraceSpan[] { + const flattened: TraceSpan[] = [] + + for (const span of spans) { + if (isSyntheticWorkflowWrapper(span)) { + if (span.children?.length) { + flattened.push(...flattenWorkflowChildren(span.children)) + } + continue + } + + const directChildren = span.children ?? [] + const outputChildren = extractOutputChildren(span.output) + const allChildren = [...directChildren, ...outputChildren] + + const nextSpan: TraceSpan = { ...span } + if (allChildren.length > 0) { + nextSpan.children = flattenWorkflowChildren(allChildren) + } + if (outputChildren.length > 0) { + nextSpan.output = stripChildTraceSpansFromOutput(nextSpan.output) + } + + flattened.push(nextSpan) + } + + return flattened +} diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index dd226ee857a..f15ff6f2fc2 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -174,11 +174,12 @@ describe('buildTraceSpans', () => { expect(traceSpans).toHaveLength(1) const agentSpan = traceSpans[0] expect(agentSpan.type).toBe('agent') - expect(agentSpan.toolCalls).toBeDefined() - expect(agentSpan.toolCalls).toHaveLength(2) + expect(agentSpan.children).toBeDefined() + expect(agentSpan.children).toHaveLength(2) // Check first tool call - const firstToolCall = agentSpan.toolCalls![0] + const firstToolCall = agentSpan.children![0] + expect(firstToolCall.type).toBe('tool') expect(firstToolCall.name).toBe('test_tool') // custom_ prefix should be stripped expect(firstToolCall.duration).toBe(1000) expect(firstToolCall.status).toBe('success') @@ -186,7 +187,8 @@ describe('buildTraceSpans', () => { expect(firstToolCall.output).toEqual({ output: 'test output' }) // Check second tool call - const secondToolCall = agentSpan.toolCalls![1] + const secondToolCall = agentSpan.children![1] + expect(secondToolCall.type).toBe('tool') expect(secondToolCall.name).toBe('http_request') expect(secondToolCall.duration).toBe(2000) expect(secondToolCall.status).toBe('success') @@ -238,10 +240,11 @@ describe('buildTraceSpans', () => { expect(traceSpans).toHaveLength(1) const agentSpan = traceSpans[0] - expect(agentSpan.toolCalls).toBeDefined() - expect(agentSpan.toolCalls).toHaveLength(1) + expect(agentSpan.children).toBeDefined() + expect(agentSpan.children).toHaveLength(1) - const toolCall = agentSpan.toolCalls![0] + const toolCall = agentSpan.children![0] + expect(toolCall.type).toBe('tool') expect(toolCall.name).toBe('serper_search') expect(toolCall.duration).toBe(1500) expect(toolCall.status).toBe('success') @@ -293,10 +296,11 @@ describe('buildTraceSpans', () => { expect(traceSpans).toHaveLength(1) const agentSpan = traceSpans[0] - expect(agentSpan.toolCalls).toBeDefined() - expect(agentSpan.toolCalls).toHaveLength(1) + expect(agentSpan.children).toBeDefined() + expect(agentSpan.children).toHaveLength(1) - const toolCall = agentSpan.toolCalls![0] + const toolCall = agentSpan.children![0] + expect(toolCall.type).toBe('tool') expect(toolCall.name).toBe('analysis_tool') // custom_ prefix should be stripped expect(toolCall.duration).toBe(2000) expect(toolCall.status).toBe('success') @@ -2082,4 +2086,124 @@ describe('nested subflow grouping via parentIterations', () => { expect(parallel!.children).toHaveLength(2) } ) + + it.concurrent('propagates per-iteration segment content to model child spans', () => { + const result: ExecutionResult = { + success: true, + output: { content: 'final' }, + logs: [ + { + blockId: 'agent-1', + blockName: 'Agent', + blockType: 'agent', + startedAt: '2024-01-01T10:00:00.000Z', + endedAt: '2024-01-01T10:00:04.000Z', + durationMs: 4000, + success: true, + input: { userPrompt: 'hi' }, + output: { + content: 'final', + model: 'claude-3-7-sonnet', + providerTiming: { + duration: 4000, + startTime: '2024-01-01T10:00:00.000Z', + endTime: '2024-01-01T10:00:04.000Z', + timeSegments: [ + { + type: 'model', + name: 'claude-3-7-sonnet', + startTime: 1704103200000, + endTime: 1704103202000, + duration: 2000, + assistantContent: 'reasoning about request', + thinkingContent: 'let me think step by step', + toolCalls: [{ id: 'call_abc', name: 'lookup', arguments: { q: 'test' } }], + finishReason: 'tool_use', + tokens: { input: 100, output: 20, total: 120, cacheRead: 5, reasoning: 8 }, + cost: { input: 0.001, output: 0.002, total: 0.003 }, + ttft: 450, + provider: 'anthropic', + }, + { + type: 'tool', + name: 'lookup', + startTime: 1704103202000, + endTime: 1704103203000, + duration: 1000, + toolCallId: 'call_abc', + errorType: 'TimeoutError', + errorMessage: 'tool timed out', + }, + { + type: 'model', + name: 'claude-3-7-sonnet', + startTime: 1704103203000, + endTime: 1704103204000, + duration: 1000, + assistantContent: 'final answer', + finishReason: 'end_turn', + tokens: { input: 130, output: 10, total: 140 }, + cost: { input: 0.002, output: 0.001, total: 0.003 }, + provider: 'anthropic', + errorType: 'RateLimitError', + errorMessage: 'too many requests', + }, + ], + }, + toolCalls: { + list: [ + { + name: 'lookup', + arguments: { q: 'test' }, + result: { hit: true }, + duration: 1000, + }, + ], + count: 1, + }, + }, + }, + ], + } + + const { traceSpans } = buildTraceSpans(result) + const children = traceSpans[0].children! + expect(children).toHaveLength(3) + + const [firstModel, tool, secondModel] = children + + expect(firstModel.type).toBe('model') + expect(firstModel.output).toEqual({ content: 'reasoning about request' }) + expect(firstModel.thinking).toBe('let me think step by step') + expect(firstModel.modelToolCalls).toEqual([ + { id: 'call_abc', name: 'lookup', arguments: { q: 'test' } }, + ]) + expect(firstModel.finishReason).toBe('tool_use') + expect(firstModel.tokens).toEqual({ + input: 100, + output: 20, + total: 120, + cacheRead: 5, + reasoning: 8, + }) + expect(firstModel.cost).toEqual({ input: 0.001, output: 0.002, total: 0.003 }) + expect(firstModel.ttft).toBe(450) + expect(firstModel.provider).toBe('anthropic') + expect(firstModel.status).toBe('success') + + expect(tool.type).toBe('tool') + expect(tool.toolCallId).toBe('call_abc') + expect(tool.errorType).toBe('TimeoutError') + expect(tool.errorMessage).toBe('tool timed out') + expect(tool.status).toBe('error') + + expect(secondModel.type).toBe('model') + expect(secondModel.output).toEqual({ content: 'final answer' }) + expect(secondModel.thinking).toBeUndefined() + expect(secondModel.modelToolCalls).toBeUndefined() + expect(secondModel.finishReason).toBe('end_turn') + expect(secondModel.errorType).toBe('RateLimitError') + expect(secondModel.errorMessage).toBe('too many requests') + expect(secondModel.status).toBe('error') + }) }) diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index f367058fd6f..1f2e2c9503a 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -1,14 +1,7 @@ -import { createLogger } from '@sim/logger' -import type { ToolCall, TraceSpan } from '@/lib/logs/types' -import { - isConditionBlockType, - isWorkflowBlockType, - stripCustomToolPrefix, -} from '@/executor/constants' -import type { ExecutionResult } from '@/executor/types' -import { stripCloneSuffixes } from '@/executor/utils/subflow-utils' - -const logger = createLogger('TraceSpans') +import { groupIterationBlocks } from '@/lib/logs/execution/trace-spans/iteration-grouping' +import { createSpanFromLog } from '@/lib/logs/execution/trace-spans/span-factory' +import type { TraceSpan } from '@/lib/logs/types' +import type { BlockLog, ExecutionResult } from '@/executor/types' /** * Keys that should be recursively filtered from output display. @@ -43,820 +36,92 @@ export function filterHiddenOutputKeys(value: unknown): unknown { return value } -function isSyntheticWorkflowWrapper(span: TraceSpan | undefined): boolean { - if (!span || span.type !== 'workflow') return false - return !span.blockId -} - -function flattenWorkflowChildren(spans: TraceSpan[]): TraceSpan[] { - const flattened: TraceSpan[] = [] - - spans.forEach((span) => { - if (isSyntheticWorkflowWrapper(span)) { - if (span.children && Array.isArray(span.children)) { - flattened.push(...flattenWorkflowChildren(span.children)) - } - return - } - - const processedSpan: TraceSpan = { ...span } - - const directChildren = Array.isArray(span.children) ? span.children : [] - const outputChildren = - span.output && - typeof span.output === 'object' && - Array.isArray((span.output as { childTraceSpans?: TraceSpan[] }).childTraceSpans) - ? ((span.output as { childTraceSpans?: TraceSpan[] }).childTraceSpans as TraceSpan[]) - : [] - - const allChildren = [...directChildren, ...outputChildren] - if (allChildren.length > 0) { - processedSpan.children = flattenWorkflowChildren(allChildren) - } - - if (outputChildren.length > 0 && processedSpan.output) { - const { childTraceSpans: _, ...cleanOutput } = processedSpan.output as { - childTraceSpans?: TraceSpan[] - } & Record - processedSpan.output = cleanOutput - } - - flattened.push(processedSpan) - }) - - return flattened -} - +/** + * Builds a hierarchical trace span tree from execution logs. + * + * Pipeline: + * 1. Each BlockLog becomes a TraceSpan via `createSpanFromLog`. + * 2. Spans are sorted by start time to form a flat list of root spans. + * 3. Loop/parallel iterations are grouped into container spans via `groupIterationBlocks`. + * 4. A synthetic "Workflow Execution" root wraps the grouped spans and provides + * relative timestamps + total duration derived from the earliest start / latest end. + */ export function buildTraceSpans(result: ExecutionResult): { traceSpans: TraceSpan[] totalDuration: number } { - if (!result.logs || result.logs.length === 0) { + if (!result.logs?.length) { return { traceSpans: [], totalDuration: 0 } } - const spanMap = new Map() - - const parentChildMap = new Map() - - type Connection = { source: string; target: string } - const workflowConnections: Connection[] = result.metadata?.workflowConnections || [] - if (workflowConnections.length > 0) { - workflowConnections.forEach((conn: Connection) => { - if (conn.source && conn.target) { - parentChildMap.set(conn.target, conn.source) - } - }) - } - - result.logs.forEach((log) => { - if (!log.blockId || !log.blockType) return - - const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` - const isCondition = isConditionBlockType(log.blockType) - - const duration = log.durationMs || 0 - - let output = log.output || {} - let childWorkflowSnapshotId: string | undefined - let childWorkflowId: string | undefined - - if (output && typeof output === 'object') { - const outputRecord = output as Record - childWorkflowSnapshotId = - typeof outputRecord.childWorkflowSnapshotId === 'string' - ? outputRecord.childWorkflowSnapshotId - : undefined - childWorkflowId = - typeof outputRecord.childWorkflowId === 'string' ? outputRecord.childWorkflowId : undefined - if (childWorkflowSnapshotId || childWorkflowId) { - const { - childWorkflowSnapshotId: _childSnapshotId, - childWorkflowId: _childWorkflowId, - ...outputRest - } = outputRecord - output = outputRest - } - } - - if (log.error) { - output = { - ...output, - error: log.error, - } - } - - const displayName = log.blockName || log.blockId - - const span: TraceSpan = { - id: spanId, - name: displayName, - type: log.blockType, - duration: duration, - startTime: log.startedAt, - endTime: log.endedAt, - status: log.error ? 'error' : 'success', - children: [], - blockId: log.blockId, - input: log.input || {}, - output: output, - ...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}), - ...(childWorkflowId ? { childWorkflowId } : {}), - ...(log.errorHandled && { errorHandled: true }), - ...(log.loopId && { loopId: log.loopId }), - ...(log.parallelId && { parallelId: log.parallelId }), - ...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }), - ...(log.parentIterations?.length && { parentIterations: log.parentIterations }), - } - - if (!isCondition && log.output?.providerTiming) { - const providerTiming = log.output.providerTiming as { - duration: number - startTime: string - endTime: string - timeSegments?: Array<{ - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }> - } - - span.providerTiming = { - duration: providerTiming.duration, - startTime: providerTiming.startTime, - endTime: providerTiming.endTime, - segments: providerTiming.timeSegments || [], - } - } - - if (!isCondition && log.output?.cost) { - span.cost = log.output.cost as { - input?: number - output?: number - total?: number - } - } - - if (!isCondition && log.output?.tokens) { - const t = log.output.tokens as - | number - | { - input?: number - output?: number - total?: number - prompt?: number - completion?: number - } - if (typeof t === 'number') { - span.tokens = t - } else if (typeof t === 'object') { - const input = t.input ?? t.prompt - const output = t.output ?? t.completion - const total = - t.total ?? - (typeof input === 'number' || typeof output === 'number' - ? (input || 0) + (output || 0) - : undefined) - span.tokens = { - ...(typeof input === 'number' ? { input } : {}), - ...(typeof output === 'number' ? { output } : {}), - ...(typeof total === 'number' ? { total } : {}), - } - } else { - span.tokens = t - } - } - - if (!isCondition && log.output?.model) { - span.model = log.output.model as string - } - - if ( - !isWorkflowBlockType(log.blockType) && - !isCondition && - log.output?.providerTiming?.timeSegments && - Array.isArray(log.output.providerTiming.timeSegments) - ) { - const timeSegments = log.output.providerTiming.timeSegments - const toolCallsData = log.output?.toolCalls?.list || log.output?.toolCalls || [] - - const toolCallsByName = new Map>>() - for (const tc of toolCallsData as Array<{ name?: string; [key: string]: unknown }>) { - const normalizedName = stripCustomToolPrefix(tc.name || '') - if (!toolCallsByName.has(normalizedName)) { - toolCallsByName.set(normalizedName, []) - } - toolCallsByName.get(normalizedName)!.push(tc) - } - - const toolCallIndices = new Map() - - span.children = timeSegments.map( - ( - segment: { - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }, - index: number - ) => { - const segmentStartTime = new Date(segment.startTime).toISOString() - let segmentEndTime = new Date(segment.endTime).toISOString() - let segmentDuration = segment.duration - - if (segment.name?.toLowerCase().includes('streaming') && log.endedAt) { - const blockEndTime = new Date(log.endedAt).getTime() - const segmentEndTimeMs = new Date(segment.endTime).getTime() - - if (blockEndTime > segmentEndTimeMs) { - segmentEndTime = log.endedAt - segmentDuration = blockEndTime - new Date(segment.startTime).getTime() - } - } - - if (segment.type === 'tool') { - const normalizedName = stripCustomToolPrefix(segment.name || '') - - const toolCallsForName = toolCallsByName.get(normalizedName) || [] - const currentIndex = toolCallIndices.get(normalizedName) || 0 - const matchingToolCall = toolCallsForName[currentIndex] as - | { - error?: string - arguments?: Record - input?: Record - result?: Record - output?: Record - } - | undefined - - toolCallIndices.set(normalizedName, currentIndex + 1) - - return { - id: `${span.id}-segment-${index}`, - name: normalizedName, - type: 'tool', - duration: segment.duration, - startTime: segmentStartTime, - endTime: segmentEndTime, - status: matchingToolCall?.error ? 'error' : 'success', - input: matchingToolCall?.arguments || matchingToolCall?.input, - output: matchingToolCall?.error - ? { - error: matchingToolCall.error, - ...(matchingToolCall.result || matchingToolCall.output || {}), - } - : matchingToolCall?.result || matchingToolCall?.output, - } - } - return { - id: `${span.id}-segment-${index}`, - name: segment.name, - type: 'model', - duration: segmentDuration, - startTime: segmentStartTime, - endTime: segmentEndTime, - status: 'success', - } - } - ) - } else if (!isCondition) { - let toolCallsList = null - - try { - if (log.output?.toolCalls?.list) { - toolCallsList = log.output.toolCalls.list - } else if (Array.isArray(log.output?.toolCalls)) { - toolCallsList = log.output.toolCalls - } else if (log.output?.executionData?.output?.toolCalls) { - const tcObj = log.output.executionData.output.toolCalls - toolCallsList = Array.isArray(tcObj) ? tcObj : tcObj.list || [] - } - - if (toolCallsList && !Array.isArray(toolCallsList)) { - logger.warn(`toolCallsList is not an array: ${typeof toolCallsList}`, { - blockId: log.blockId, - blockType: log.blockType, - }) - toolCallsList = [] - } - } catch (error) { - logger.error(`Error extracting toolCalls from block ${log.blockId}:`, error) - toolCallsList = [] - } - - if (toolCallsList && toolCallsList.length > 0) { - const processedToolCalls: ToolCall[] = [] - - for (const tc of toolCallsList as Array<{ - name?: string - duration?: number - startTime?: string - endTime?: string - error?: string - arguments?: Record - input?: Record - result?: Record - output?: Record - }>) { - if (!tc) continue - - try { - const toolCall: ToolCall = { - name: stripCustomToolPrefix(tc.name || 'unnamed-tool'), - duration: tc.duration || 0, - startTime: tc.startTime || log.startedAt, - endTime: tc.endTime || log.endedAt, - status: tc.error ? 'error' : 'success', - } - - if (tc.arguments || tc.input) { - toolCall.input = tc.arguments || tc.input - } - - if (tc.result || tc.output) { - toolCall.output = tc.result || tc.output - } - - if (tc.error) { - toolCall.error = tc.error - } - - processedToolCalls.push(toolCall) - } catch (tcError) { - logger.error(`Error processing tool call in block ${log.blockId}:`, tcError) - } - } - - span.toolCalls = processedToolCalls - } - } - - if (isWorkflowBlockType(log.blockType)) { - const childTraceSpans = Array.isArray(log.childTraceSpans) - ? log.childTraceSpans - : Array.isArray(log.output?.childTraceSpans) - ? (log.output.childTraceSpans as TraceSpan[]) - : null - - if (childTraceSpans) { - const flattenedChildren = flattenWorkflowChildren(childTraceSpans) - span.children = flattenedChildren - - if (span.output && typeof span.output === 'object' && 'childTraceSpans' in span.output) { - const { childTraceSpans: _, ...cleanOutput } = span.output as { - childTraceSpans?: TraceSpan[] - } & Record - span.output = cleanOutput - } - } - } - - spanMap.set(spanId, span) - }) - - const sortedLogs = [...result.logs].sort((a, b) => { - const aTime = new Date(a.startedAt).getTime() - const bTime = new Date(b.startedAt).getTime() - return aTime - bTime - }) - - const rootSpans: TraceSpan[] = [] - - sortedLogs.forEach((log) => { - if (!log.blockId) return - - const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` - const span = spanMap.get(spanId) - if (span) { - rootSpans.push(span) - } - }) - - if (rootSpans.length === 0 && workflowConnections.length === 0) { - const spanStack: TraceSpan[] = [] - - sortedLogs.forEach((log) => { - if (!log.blockId || !log.blockType) return - - const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` - const span = spanMap.get(spanId) - if (!span) return - - if (spanStack.length > 0) { - const potentialParent = spanStack[spanStack.length - 1] - const parentStartTime = new Date(potentialParent.startTime).getTime() - const parentEndTime = new Date(potentialParent.endTime).getTime() - const spanStartTime = new Date(span.startTime).getTime() - - if (spanStartTime >= parentStartTime && spanStartTime <= parentEndTime) { - if (!potentialParent.children) potentialParent.children = [] - potentialParent.children.push(span) - } else { - while ( - spanStack.length > 0 && - new Date(spanStack[spanStack.length - 1].endTime).getTime() < spanStartTime - ) { - spanStack.pop() - } + const spans = buildRootSpansFromLogs(result.logs) + const grouped = groupIterationBlocks(spans) - if (spanStack.length > 0) { - const newParent = spanStack[spanStack.length - 1] - if (!newParent.children) newParent.children = [] - newParent.children.push(span) - } else { - rootSpans.push(span) - } - } - } else { - rootSpans.push(span) - } - - if (log.blockType === 'agent' || isWorkflowBlockType(log.blockType)) { - spanStack.push(span) - } - }) + if (grouped.length === 0 || !result.metadata) { + const totalDuration = grouped.reduce((sum, span) => sum + span.duration, 0) + return { traceSpans: grouped, totalDuration } } - const groupedRootSpans = groupIterationBlocks(rootSpans) - - const totalDuration = groupedRootSpans.reduce((sum, span) => sum + span.duration, 0) - - if (groupedRootSpans.length > 0 && result.metadata) { - const allSpansList = Array.from(spanMap.values()) - - const earliestStart = allSpansList.reduce((earliest, span) => { - const startTime = new Date(span.startTime).getTime() - return startTime < earliest ? startTime : earliest - }, Number.POSITIVE_INFINITY) - - const latestEnd = allSpansList.reduce((latest, span) => { - const endTime = new Date(span.endTime).getTime() - return endTime > latest ? endTime : latest - }, 0) - - const actualWorkflowDuration = latestEnd - earliestStart - - const addRelativeTimestamps = (spans: TraceSpan[], workflowStartMs: number) => { - spans.forEach((span) => { - span.relativeStartMs = new Date(span.startTime).getTime() - workflowStartMs - if (span.children && span.children.length > 0) { - addRelativeTimestamps(span.children, workflowStartMs) - } - }) - } - addRelativeTimestamps(groupedRootSpans, earliestStart) - - const checkForUnhandledErrors = (s: TraceSpan): boolean => { - if (s.status === 'error' && !s.errorHandled) return true - return s.children ? s.children.some(checkForUnhandledErrors) : false - } - const hasUnhandledErrors = groupedRootSpans.some(checkForUnhandledErrors) - - const workflowSpan: TraceSpan = { - id: 'workflow-execution', - name: 'Workflow Execution', - type: 'workflow', - duration: actualWorkflowDuration, // Always use actual duration for the span - startTime: new Date(earliestStart).toISOString(), - endTime: new Date(latestEnd).toISOString(), - status: hasUnhandledErrors ? 'error' : 'success', - children: groupedRootSpans, - } + return wrapInWorkflowRoot(grouped, spans) +} - return { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration } +/** Converts each BlockLog into a TraceSpan, sorted chronologically by start time. */ +function buildRootSpansFromLogs(logs: BlockLog[]): TraceSpan[] { + const spans: TraceSpan[] = [] + for (const log of logs) { + const span = createSpanFromLog(log) + if (span) spans.push(span) } - - return { traceSpans: groupedRootSpans, totalDuration } + spans.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + return spans } /** - * Builds a container-level TraceSpan (iteration wrapper or top-level container) - * from its source spans and resolved children. + * Wraps grouped spans in a synthetic workflow-execution root span using the + * true workflow bounds (earliest start / latest end across all leaf spans). */ -function buildContainerSpan(opts: { - id: string - name: string - type: string - sourceSpans: TraceSpan[] - children: TraceSpan[] -}): TraceSpan { - const startTimes = opts.sourceSpans.map((s) => new Date(s.startTime).getTime()) - const endTimes = opts.sourceSpans.map((s) => new Date(s.endTime).getTime()) - const earliestStart = Math.min(...startTimes) - const latestEnd = Math.max(...endTimes) - - const hasErrors = opts.sourceSpans.some((s) => s.status === 'error') - const allErrorsHandled = - hasErrors && opts.children.every((s) => s.status !== 'error' || s.errorHandled) - - return { - id: opts.id, - name: opts.name, - type: opts.type, - duration: latestEnd - earliestStart, +function wrapInWorkflowRoot( + grouped: TraceSpan[], + leafSpans: TraceSpan[] +): { traceSpans: TraceSpan[]; totalDuration: number } { + let earliestStart = Number.POSITIVE_INFINITY + let latestEnd = 0 + for (const span of leafSpans) { + const startTime = new Date(span.startTime).getTime() + const endTime = new Date(span.endTime).getTime() + if (startTime < earliestStart) earliestStart = startTime + if (endTime > latestEnd) latestEnd = endTime + } + + const actualWorkflowDuration = latestEnd - earliestStart + addRelativeTimestamps(grouped, earliestStart) + + const workflowSpan: TraceSpan = { + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + duration: actualWorkflowDuration, startTime: new Date(earliestStart).toISOString(), endTime: new Date(latestEnd).toISOString(), - status: hasErrors ? 'error' : 'success', - ...(allErrorsHandled && { errorHandled: true }), - children: opts.children, + status: grouped.some(hasUnhandledError) ? 'error' : 'success', + children: grouped, } -} -/** Counter state for generating sequential container names. */ -interface ContainerNameCounters { - loopNumbers: Map - parallelNumbers: Map - loopCounter: number - parallelCounter: number + return { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration } } -/** - * Resolves a container name from normal (non-iteration) spans or assigns a sequential number. - * Strips clone suffixes so all clones of the same container share one name/number. - */ -function resolveContainerName( - containerId: string, - containerType: 'parallel' | 'loop', - normalSpans: TraceSpan[], - counters: ContainerNameCounters -): string { - const originalId = stripCloneSuffixes(containerId) - - const matchingBlock = normalSpans.find( - (s) => s.blockId === originalId && s.type === containerType - ) - if (matchingBlock?.name) return matchingBlock.name - - if (containerType === 'parallel') { - if (!counters.parallelNumbers.has(originalId)) { - counters.parallelNumbers.set(originalId, counters.parallelCounter++) - } - return `Parallel ${counters.parallelNumbers.get(originalId)}` - } - if (!counters.loopNumbers.has(originalId)) { - counters.loopNumbers.set(originalId, counters.loopCounter++) - } - return `Loop ${counters.loopNumbers.get(originalId)}` -} - -/** - * Classifies a span's immediate container ID and type from its metadata. - * Returns undefined for non-iteration spans. - */ -function classifySpanContainer( - span: TraceSpan -): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { - if (span.parallelId) { - return { containerId: span.parallelId, containerType: 'parallel' } - } - if (span.loopId) { - return { containerId: span.loopId, containerType: 'loop' } - } - // Fallback: parse from blockId for legacy data - if (span.blockId?.includes('_parallel_')) { - const match = span.blockId.match(/_parallel_([^_]+)_iteration_/) - if (match) { - return { containerId: match[1], containerType: 'parallel' } - } - } - return undefined -} - -/** - * Finds the outermost container for a span. For nested spans, this is parentIterations[0]. - * For flat spans, this is the span's own immediate container. - */ -function getOutermostContainer( - span: TraceSpan -): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { - if (span.parentIterations && span.parentIterations.length > 0) { - const outermost = span.parentIterations[0] - return { - containerId: outermost.iterationContainerId, - containerType: outermost.iterationType as 'parallel' | 'loop', - } - } - return classifySpanContainer(span) -} - -/** - * Builds the iteration-level hierarchy for a container, recursively nesting - * any deeper subflows. Works with both: - * - Direct spans (spans whose immediate container matches) - * - Nested spans (spans with parentIterations pointing through this container) - */ -function buildContainerChildren( - containerType: 'parallel' | 'loop', - containerId: string, - spans: TraceSpan[], - normalSpans: TraceSpan[], - counters: ContainerNameCounters -): TraceSpan[] { - const iterationType = containerType === 'parallel' ? 'parallel-iteration' : 'loop-iteration' - - // Group spans by iteration index at this level. - // Each span's iteration index at this level comes from: - // - parentIterations[0].iterationCurrent if parentIterations[0].containerId === containerId - // - span.iterationIndex if span's immediate container === containerId - const iterationGroups = new Map() - - for (const span of spans) { - let iterIdx: number | undefined - - if ( - span.parentIterations && - span.parentIterations.length > 0 && - span.parentIterations[0].iterationContainerId === containerId - ) { - iterIdx = span.parentIterations[0].iterationCurrent - } else { - // The span's immediate container is this container - iterIdx = span.iterationIndex - } - - if (iterIdx === undefined) continue - - if (!iterationGroups.has(iterIdx)) iterationGroups.set(iterIdx, []) - iterationGroups.get(iterIdx)!.push(span) - } - - const iterationChildren: TraceSpan[] = [] - const sortedIterations = Array.from(iterationGroups.entries()).sort(([a], [b]) => a - b) - - for (const [iterationIndex, iterSpans] of sortedIterations) { - // For each span in this iteration, strip one level of ancestry and determine - // whether it belongs to this container directly or to a deeper subflow - const directLeaves: TraceSpan[] = [] - const deeperSpans: TraceSpan[] = [] - - for (const span of iterSpans) { - if ( - span.parentIterations && - span.parentIterations.length > 0 && - span.parentIterations[0].iterationContainerId === containerId - ) { - // Strip the outermost parentIteration (this container level) - deeperSpans.push({ - ...span, - parentIterations: span.parentIterations.slice(1), - }) - } else { - // This span's immediate container IS this container — it's a direct leaf - directLeaves.push({ - ...span, - name: span.name.replace(/ \(iteration \d+\)$/, ''), - }) - } - } - - // Recursively group the deeper spans (they'll form nested containers) - const nestedResult = groupIterationBlocksRecursive( - [...directLeaves, ...deeperSpans], - normalSpans, - counters - ) - - iterationChildren.push( - buildContainerSpan({ - id: `${containerId}-iteration-${iterationIndex}`, - name: `Iteration ${iterationIndex}`, - type: iterationType, - sourceSpans: iterSpans, - children: nestedResult, - }) - ) - } - - return iterationChildren -} - -/** - * Core recursive algorithm for grouping iteration blocks. - * - * Handles two cases: - * 1. **Flat** (backward compat): spans have loopId/parallelId + iterationIndex but no - * parentIterations. Grouped by immediate container → iteration → leaf. - * 2. **Nested** (new): spans have parentIterations chains. The outermost ancestor in the - * chain determines the top-level container. Iteration spans are peeled one level at a - * time and recursed. - * - * Container BlockLogs (parallel/loop) are produced on skip (empty collection), error, and - * successful completion. When present, they supply the user-configured container name via - * `resolveContainerName`; otherwise the container is synthesized from iteration data with a - * counter-based fallback name. - */ -function groupIterationBlocksRecursive( - spans: TraceSpan[], - normalSpans: TraceSpan[], - counters: ContainerNameCounters -): TraceSpan[] { - const result: TraceSpan[] = [] - const iterationSpans: TraceSpan[] = [] - const nonIterationSpans: TraceSpan[] = [] - +/** Recursively annotates spans with `relativeStartMs` (ms since workflow start). */ +function addRelativeTimestamps(spans: TraceSpan[], workflowStartMs: number): void { for (const span of spans) { - if ( - span.name.match(/^(.+) \(iteration (\d+)\)$/) || - (span.parentIterations && span.parentIterations.length > 0) - ) { - iterationSpans.push(span) - } else { - nonIterationSpans.push(span) - } - } - - const containerIdsWithIterations = new Set() - for (const span of iterationSpans) { - const outermost = getOutermostContainer(span) - if (outermost) containerIdsWithIterations.add(outermost.containerId) - } - - const nonContainerSpans = nonIterationSpans.filter( - (span) => - (span.type !== 'parallel' && span.type !== 'loop') || - span.status === 'error' || - (span.blockId && !containerIdsWithIterations.has(span.blockId)) - ) - - if (iterationSpans.length === 0) { - result.push(...nonContainerSpans) - result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) - return result - } - - // Group iteration spans by outermost container - const containerGroups = new Map< - string, - { type: 'parallel' | 'loop'; containerId: string; containerName: string; spans: TraceSpan[] } - >() - - for (const span of iterationSpans) { - const outermost = getOutermostContainer(span) - if (!outermost) continue - - const { containerId, containerType } = outermost - const groupKey = `${containerType}_${containerId}` - - if (!containerGroups.has(groupKey)) { - const containerName = resolveContainerName(containerId, containerType, normalSpans, counters) - containerGroups.set(groupKey, { - type: containerType, - containerId, - containerName, - spans: [], - }) + span.relativeStartMs = new Date(span.startTime).getTime() - workflowStartMs + if (span.children?.length) { + addRelativeTimestamps(span.children, workflowStartMs) } - containerGroups.get(groupKey)!.spans.push(span) - } - - // Build each container with recursive nesting - for (const [, group] of containerGroups) { - const { type, containerId, containerName, spans: containerSpans } = group - - const iterationChildren = buildContainerChildren( - type, - containerId, - containerSpans, - normalSpans, - counters - ) - - result.push( - buildContainerSpan({ - id: `${type === 'parallel' ? 'parallel' : 'loop'}-execution-${containerId}`, - name: containerName, - type, - sourceSpans: containerSpans, - children: iterationChildren, - }) - ) } - - result.push(...nonContainerSpans) - result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) - - return result } -/** - * Groups iteration-based blocks (parallel and loop) by organizing their iteration spans - * into a hierarchical structure with proper parent-child relationships. - * Supports recursive nesting via parentIterations (e.g., parallel-in-parallel, loop-in-loop). - * - * @param spans - Array of root spans to process - * @returns Array of spans with iteration blocks properly grouped - */ -function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] { - const normalSpans = spans.filter((s) => !s.name.match(/^(.+) \(iteration (\d+)\)$/)) - const counters: ContainerNameCounters = { - loopNumbers: new Map(), - parallelNumbers: new Map(), - loopCounter: 1, - parallelCounter: 1, - } - return groupIterationBlocksRecursive(spans, normalSpans, counters) +/** True if this span (or any descendant) has an unhandled error. */ +function hasUnhandledError(span: TraceSpan): boolean { + if (span.status === 'error' && !span.errorHandled) return true + return span.children?.some(hasUnhandledError) ?? false } diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index 20f568ab41c..018adf429c0 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -1,7 +1,13 @@ import type { Edge } from 'reactflow' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import type { ParentIteration, SerializableExecutionState } from '@/executor/execution/types' -import type { BlockLog, NormalizedBlockOutput } from '@/executor/types' +import type { + BlockLog, + BlockTokens, + IterationToolCall, + NormalizedBlockOutput, + ProviderTimingSegment, +} from '@/executor/types' import type { Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' export type { WorkflowState, Loop, Parallel } @@ -179,25 +185,13 @@ export interface WorkflowExecutionLog { export type WorkflowExecutionLogInsert = Omit export type WorkflowExecutionLogSelect = WorkflowExecutionLog -export interface TokenInfo { - input?: number - output?: number - total?: number - prompt?: number - completion?: number -} +export type TokenInfo = BlockTokens export interface ProviderTiming { duration: number startTime: string endTime: string - segments: Array<{ - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }> + segments: ProviderTimingSegment[] } export interface TraceSpan { @@ -208,11 +202,15 @@ export interface TraceSpan { startTime: string endTime: string children?: TraceSpan[] + /** + * @deprecated Tool invocations are emitted as `children` with `type: 'tool'`. + * This field only appears on legacy trace spans persisted before the unification. + */ toolCalls?: ToolCall[] status?: 'success' | 'error' /** Whether this block's error was handled by an error handler path */ errorHandled?: boolean - tokens?: number | TokenInfo + tokens?: TokenInfo relativeStartMs?: number blockId?: string input?: Record @@ -230,6 +228,43 @@ export interface TraceSpan { parallelId?: string iterationIndex?: number parentIterations?: ParentIteration[] + /** + * For model child spans: the assistant's thinking/reasoning blocks from this + * iteration, stringified. Surfaces Anthropic extended thinking and equivalents. + */ + thinking?: string + /** + * For model child spans: the tool calls the assistant requested in this + * iteration. `id` is the provider-assigned `tool_call.id`, used to correlate + * the following tool child span via its `toolCallId` field. + */ + modelToolCalls?: IterationToolCall[] + /** + * For model child spans: the provider-reported stop reason + * (`stop`, `tool_use`, `length`, …). + */ + finishReason?: string + /** + * For tool child spans: the `tool_call.id` this tool invocation satisfies. + * Matches one of the preceding model child's `modelToolCalls[i].id`. + */ + toolCallId?: string + /** + * For model child spans: time-to-first-token in ms (streaming runs only). + */ + ttft?: number + /** + * For model child spans: the provider system identifier + * (`anthropic`, `openai`, `gemini`, …) — aligns with OTel `gen_ai.system`. + */ + provider?: string + /** + * For failed child spans: structured error class + * (e.g. `rate_limit`, `context_length`). + */ + errorType?: string + /** For failed child spans: human-readable error message. */ + errorMessage?: string } export interface WorkflowExecutionSummary { diff --git a/apps/sim/lib/tokenization/streaming.ts b/apps/sim/lib/tokenization/streaming.ts index 047fd0b8b38..ca552fa8292 100644 --- a/apps/sim/lib/tokenization/streaming.ts +++ b/apps/sim/lib/tokenization/streaming.ts @@ -49,13 +49,19 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string) const inputText = extractTextContent(log.input) // Calculate streaming cost + const systemPrompt = + typeof log.input?.systemPrompt === 'string' ? log.input.systemPrompt : undefined + const context = typeof log.input?.context === 'string' ? log.input.context : undefined + const messages = Array.isArray(log.input?.messages) + ? (log.input.messages as Array<{ role: string; content: string }>) + : undefined const result = calculateStreamingCost( model, inputText, streamedContent, - log.input?.systemPrompt, - log.input?.context, - log.input?.messages + systemPrompt, + context, + messages ) // Update the log output with tokenization data @@ -102,8 +108,9 @@ function getModelForBlock(log: BlockLog): string { } // Try to get model from input - if (log.input?.model?.trim()) { - return log.input.model + const inputModel = log.input?.model + if (typeof inputModel === 'string' && inputModel.trim()) { + return inputModel } // Use block type specific defaults diff --git a/apps/sim/lib/tokenization/utils.ts b/apps/sim/lib/tokenization/utils.ts index e3c3c3287d0..72bcf9ac420 100644 --- a/apps/sim/lib/tokenization/utils.ts +++ b/apps/sim/lib/tokenization/utils.ts @@ -11,6 +11,7 @@ import { } from '@/lib/tokenization/constants' import { createTokenizationError } from '@/lib/tokenization/errors' import type { ProviderTokenizationConfig, TokenUsage } from '@/lib/tokenization/types' +import type { BlockTokens } from '@/executor/types' import { getProviderFromModel } from '@/providers/utils' const logger = createLogger('TokenizationUtils') @@ -56,9 +57,11 @@ export function isTokenizableBlockType(blockType?: string): boolean { /** * Checks if tokens/cost data is meaningful (non-zero) */ -export function hasRealTokenData(tokens?: TokenUsage): boolean { +export function hasRealTokenData( + tokens?: Pick +): boolean { if (!tokens) return false - return tokens.total > 0 || tokens.input > 0 || tokens.output > 0 + return (tokens.total ?? 0) > 0 || (tokens.input ?? 0) > 0 || (tokens.output ?? 0) > 0 } /** diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index c51d1420188..bda5c2f6f4a 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,7 +3,7 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import type { StreamingExecution } from '@/executor/types' +import type { BlockTokens, IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { checkForForcedToolUsage, @@ -15,6 +15,7 @@ import { supportsNativeStructuredOutputs, supportsTemperature, } from '@/providers/models' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' import { @@ -446,7 +447,7 @@ export async function executeAnthropicProviderRequest( timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -516,7 +517,7 @@ export async function executeAnthropicProviderRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -546,6 +547,11 @@ export async function executeAnthropicProviderRequest( } const toolUses = currentResponse.content.filter((item) => item.type === 'tool_use') + + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { + model: request.model, + }) + if (!toolUses || toolUses.length === 0) { break } @@ -622,6 +628,7 @@ export async function executeAnthropicProviderRequest( startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolUse.id, }) let resultContent: unknown @@ -751,7 +758,7 @@ export async function executeAnthropicProviderRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -768,6 +775,16 @@ export async function executeAnthropicProviderRequest( iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + const trailingText = currentResponse.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n') + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { + model: request.model, + }) + } } catch (error) { logger.error(`Error in ${providerLabel} request:`, { error }) throw error @@ -930,7 +947,7 @@ export async function executeAnthropicProviderRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -960,6 +977,11 @@ export async function executeAnthropicProviderRequest( } const toolUses = currentResponse.content.filter((item) => item.type === 'tool_use') + + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { + model: request.model, + }) + if (!toolUses || toolUses.length === 0) { break } @@ -1038,6 +1060,7 @@ export async function executeAnthropicProviderRequest( startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolUseId, }) let resultContent: unknown @@ -1165,7 +1188,7 @@ export async function executeAnthropicProviderRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -1191,6 +1214,16 @@ export async function executeAnthropicProviderRequest( iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + const trailingText = currentResponse.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n') + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { + model: request.model, + }) + } } catch (error) { logger.error(`Error in ${providerLabel} request:`, { error }) throw error @@ -1336,3 +1369,87 @@ export async function executeAnthropicProviderRequest( }) } } + +/** + * Enriches the last model segment with content from an Anthropic `Message`: + * assistant text, thinking/redacted_thinking blocks, tool_use calls (with IDs), + * stop_reason, and per-iteration tokens. + */ +function enrichLastModelSegmentFromAnthropicResponse( + timeSegments: TimeSegment[], + response: Anthropic.Messages.Message, + textContent: string, + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const thinkingBlocks = response.content.filter( + (item): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => + item.type === 'thinking' || item.type === 'redacted_thinking' + ) + const thinkingContent = thinkingBlocks + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + + const toolUseBlocks = response.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + const toolCalls: IterationToolCall[] = toolUseBlocks.map((t) => ({ + id: t.id, + name: t.name, + arguments: + t.input && typeof t.input === 'object' && !Array.isArray(t.input) + ? (t.input as Record) + : {}, + })) + + const segmentTokens = response.usage ? buildAnthropicSegmentTokens(response.usage) : undefined + + let cost: { input: number; output: number; total: number } | undefined + if ( + extras?.model && + segmentTokens && + typeof segmentTokens.input === 'number' && + typeof segmentTokens.output === 'number' + ) { + const useCached = (segmentTokens.cacheRead ?? 0) > 0 + const full = calculateCost(extras.model, segmentTokens.input, segmentTokens.output, useCached) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: textContent || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: response.stop_reason ?? undefined, + tokens: segmentTokens, + cost, + provider: 'anthropic', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} + +/** + * Builds a segment token breakdown from Anthropic usage data, surfacing prompt + * cache reads/writes separately and producing a corrected `total` that includes + * cache_creation tokens (which Anthropic bills as input tokens but omits from + * `input_tokens`). + */ +function buildAnthropicSegmentTokens(usage: Anthropic.Messages.Message['usage']): BlockTokens { + const input = usage.input_tokens ?? 0 + const output = usage.output_tokens ?? 0 + const cacheRead = usage.cache_read_input_tokens ?? 0 + const cacheWrite = usage.cache_creation_input_tokens ?? 0 + return { + input, + output, + total: input + output + cacheRead + cacheWrite, + ...(cacheRead > 0 && { cacheRead }), + ...(cacheWrite > 0 && { cacheWrite }), + } +} diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index d60354c77af..a5c9fcd633f 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -25,6 +25,7 @@ import { } from '@/providers/azure-openai/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeResponsesProviderRequest } from '@/providers/openai/core' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, ProviderConfig, @@ -223,7 +224,7 @@ async function executeChatCompletionsRequest( timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -272,13 +273,20 @@ async function executeChatCompletionsRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, }, ] + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'azure_openai' } + ) + const firstCheckResult = checkForForcedToolUsage( currentResponse, originalToolChoice ?? 'auto', @@ -450,12 +458,19 @@ async function executeChatCompletionsRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, }) + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'azure_openai' } + ) + modelTime += thisModelTime if (currentResponse.choices[0]?.message?.content) { diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index f054d781999..31c8d14cfc6 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -5,6 +5,7 @@ import { type ContentBlock, type ConversationRole, ConverseCommand, + type ConverseResponse, ConverseStreamCommand, type SystemContentBlock, type Tool, @@ -14,7 +15,7 @@ import { } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import type { StreamingExecution } from '@/executor/types' +import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { checkForForcedToolUsage, @@ -23,6 +24,7 @@ import { getBedrockInferenceProfileId, } from '@/providers/bedrock/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, ProviderConfig, @@ -41,6 +43,62 @@ import { executeTool } from '@/tools' const logger = createLogger('BedrockProvider') +function enrichLastModelSegmentFromBedrockResponse( + timeSegments: TimeSegment[], + response: ConverseResponse, + extras: { model: string } +): void { + const blocks: ContentBlock[] = response.output?.message?.content ?? [] + + const assistantText = blocks + .filter((b): b is ContentBlock & { text: string } => 'text' in b && typeof b.text === 'string') + .map((b) => b.text) + .join('\n') + const assistantContent = assistantText.length > 0 ? assistantText : undefined + + const toolCalls: IterationToolCall[] = blocks + .filter((b): b is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in b && !!b.toolUse) + .map((b) => { + const input = b.toolUse.input + return { + id: b.toolUse.toolUseId ?? '', + name: b.toolUse.name ?? '', + arguments: + input && typeof input === 'object' && !Array.isArray(input) + ? (input as Record) + : {}, + } + }) + + const inputTokens = response.usage?.inputTokens + const outputTokens = response.usage?.outputTokens + + let cost: { input: number; output: number; total: number } | undefined + if (typeof inputTokens === 'number' && typeof outputTokens === 'number') { + const full = calculateCost(extras.model, inputTokens, outputTokens) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: response.stopReason ?? undefined, + tokens: + inputTokens !== undefined || outputTokens !== undefined + ? { + input: inputTokens, + output: outputTokens, + total: + typeof inputTokens === 'number' && typeof outputTokens === 'number' + ? inputTokens + outputTokens + : undefined, + } + : undefined, + cost, + provider: 'aws.bedrock', + }) +} + export const bedrockProvider: ProviderConfig = { id: 'bedrock', name: 'AWS Bedrock', @@ -345,7 +403,7 @@ export const bedrockProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -444,13 +502,17 @@ export const bedrockProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, }, ] + enrichLastModelSegmentFromBedrockResponse(timeSegments, currentResponse, { + model: request.model, + }) + const initialToolUseContentBlocks = (currentResponse.output?.message?.content || []).filter( (block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block ) @@ -668,12 +730,16 @@ export const bedrockProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, }) + enrichLastModelSegmentFromBedrockResponse(timeSegments, currentResponse, { + model: request.model, + }) + modelTime += thisModelTime if (currentResponse.usage) { @@ -725,6 +791,10 @@ export const bedrockProvider: ProviderConfig = { duration: structuredOutputEndTime - structuredOutputStartTime, }) + enrichLastModelSegmentFromBedrockResponse(timeSegments, structuredResponse, { + model: request.model, + }) + modelTime += structuredOutputEndTime - structuredOutputStartTime const structuredOutputCall = structuredResponse.output?.message?.content?.find( diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 2bdfcdc1722..fe6f0bba76c 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -6,6 +6,7 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import type { CerebrasResponse } from '@/providers/cerebras/types' import { createReadableStreamFromCerebrasStream } from '@/providers/cerebras/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -161,7 +162,7 @@ export const cerebrasProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -206,7 +207,7 @@ export const cerebrasProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -219,6 +220,13 @@ export const cerebrasProvider: ProviderConfig = { while (iterationCount < MAX_TOOL_ITERATIONS) { const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'cerebras' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { if (currentResponse.choices[0]?.message?.content) { content = currentResponse.choices[0].message.content @@ -313,6 +321,7 @@ export const cerebrasProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any if (result.success && result.output) { @@ -382,7 +391,7 @@ export const cerebrasProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: 'Final response', + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -399,6 +408,13 @@ export const cerebrasProvider: ProviderConfig = { tokens.total += finalResponse.usage.total_tokens || 0 } + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'cerebras' } + ) + break } @@ -419,7 +435,7 @@ export const cerebrasProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -435,6 +451,15 @@ export const cerebrasProvider: ProviderConfig = { iterationCount++ } } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'cerebras' } + ) + } } catch (error) { logger.error('Error in Cerebras tool processing:', { error }) } @@ -564,3 +589,8 @@ export const cerebrasProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index bd4abf1ace4..6f5c0612e3d 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -5,6 +5,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -161,7 +162,7 @@ export const deepseekProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -217,7 +218,7 @@ export const deepseekProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -248,6 +249,14 @@ export const deepseekProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'deepseek' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -324,6 +333,7 @@ export const deepseekProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -410,7 +420,7 @@ export const deepseekProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -432,6 +442,15 @@ export const deepseekProvider: ProviderConfig = { iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'deepseek' } + ) + } } catch (error) { logger.error('Error in Deepseek request:', { error }) } diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 08d24584f96..6aa336ec7b9 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -10,6 +10,7 @@ import { supportsNativeStructuredOutputs, } from '@/providers/fireworks/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, Message, @@ -209,7 +210,7 @@ export const fireworksProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -257,7 +258,7 @@ export const fireworksProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -279,6 +280,14 @@ export const fireworksProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'fireworks' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -358,6 +367,7 @@ export const fireworksProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -423,7 +433,7 @@ export const fireworksProvider: ProviderConfig = { const thisModelTime = nextModelEndTime - nextModelStartTime timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -440,6 +450,15 @@ export const fireworksProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'fireworks' } + ) + } + if (request.stream) { const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) @@ -572,6 +591,13 @@ export const fireworksProvider: ProviderConfig = { tokens.output += finalResponse.usage.completion_tokens || 0 tokens.total += finalResponse.usage.total_tokens || 0 } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'fireworks' } + ) } const providerEndTime = Date.now() @@ -622,3 +648,8 @@ export const fireworksProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 786975eabcc..e22baeda8e7 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -13,7 +13,7 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import type { StreamingExecution } from '@/executor/types' +import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { checkForForcedToolUsage, @@ -26,7 +26,13 @@ import { extractTextContent, mapToThinkingLevel, } from '@/providers/google/utils' -import type { FunctionCallResponse, ProviderRequest, ProviderResponse } from '@/providers/types' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { + FunctionCallResponse, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' import { calculateCost, isDeepResearchModel, @@ -71,7 +77,7 @@ function createInitialState( timeSegments: [ { type: 'model', - name: 'Initial response', + name: model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -218,6 +224,7 @@ async function executeToolCallsBatch( startTime: r.startTime, endTime: r.endTime, duration: r.duration, + toolCallId: r.part.functionCall?.id ?? undefined, }) totalToolsTime += r.duration @@ -279,7 +286,7 @@ function updateStateWithResponse( ...state.timeSegments, { type: 'model', - name: `Model response (iteration ${state.iterationCount + 1})`, + name: model, startTime, endTime, duration, @@ -1074,6 +1081,9 @@ export async function executeGeminiRequest( model, toolConfig ) + enrichLastModelSegmentFromGeminiResponse(state.timeSegments, response, { + model, + }) const forcedTools = preparedTools?.forcedTools ?? [] let currentResponse = response @@ -1122,6 +1132,9 @@ export async function executeGeminiRequest( config: nextConfig, }) state = updateStateWithResponse(state, checkResponse, model, Date.now() - 100, Date.now()) + enrichLastModelSegmentFromGeminiResponse(state.timeSegments, checkResponse, { + model, + }) if (checkResponse.functionCalls?.length) { currentResponse = checkResponse @@ -1207,6 +1220,9 @@ export async function executeGeminiRequest( config: nextConfig, }) state = updateStateWithResponse(state, nextResponse, model, nextModelStartTime, Date.now()) + enrichLastModelSegmentFromGeminiResponse(state.timeSegments, nextResponse, { + model, + }) currentResponse = nextResponse } @@ -1257,3 +1273,80 @@ export async function executeGeminiRequest( throw enhancedError } } + +/** + * Enriches the last model segment with per-iteration content extracted from a + * Gemini response: assistant text, thinking (thought) parts, function calls, + * finish reason, and token usage. + */ +function enrichLastModelSegmentFromGeminiResponse( + timeSegments: TimeSegment[], + response: GenerateContentResponse, + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const candidate = response.candidates?.[0] + const assistantText = extractTextContent(candidate) + + const thinkingParts = + candidate?.content?.parts?.filter((p): p is Part & { text: string } => + Boolean(p.text && p.thought === true) + ) ?? [] + const thinkingContent = thinkingParts.map((p) => p.text).join('\n\n') + + const functionCallParts = extractAllFunctionCallParts(candidate) + const toolCalls: IterationToolCall[] = functionCallParts + .filter((p): p is Part & { functionCall: NonNullable } => + Boolean(p.functionCall) + ) + .map((p) => ({ + id: p.functionCall.id ?? '', + name: p.functionCall.name ?? '', + arguments: (p.functionCall.args ?? {}) as Record, + })) + + const usage = convertUsageMetadata(response.usageMetadata) + const cachedContentTokens = response.usageMetadata?.cachedContentTokenCount ?? 0 + const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount ?? 0 + + let cost: { input: number; output: number; total: number } | undefined + if ( + extras?.model && + response.usageMetadata && + typeof usage.promptTokenCount === 'number' && + typeof usage.candidatesTokenCount === 'number' + ) { + const full = calculateCost( + extras.model, + usage.promptTokenCount, + usage.candidatesTokenCount, + cachedContentTokens > 0 + ) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: candidate?.finishReason ?? undefined, + tokens: response.usageMetadata + ? { + input: usage.promptTokenCount, + output: usage.candidatesTokenCount, + total: usage.totalTokenCount, + ...(cachedContentTokens > 0 && { cacheRead: cachedContentTokens }), + ...(thoughtsTokens > 0 && { reasoning: thoughtsTokens }), + } + : undefined, + cost, + provider: 'google', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index fba8984e86b..192e1412d94 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -5,6 +5,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -162,7 +163,7 @@ export const groqProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -212,7 +213,7 @@ export const groqProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -226,6 +227,14 @@ export const groqProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'groq' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -302,6 +311,7 @@ export const groqProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -373,7 +383,7 @@ export const groqProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -393,6 +403,15 @@ export const groqProvider: ProviderConfig = { iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'groq' } + ) + } } catch (error) { logger.error('Error in Groq request:', { error }) } diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 32e24c1f329..ffe1ecad930 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -6,6 +6,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -200,7 +201,7 @@ export const mistralProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -272,7 +273,7 @@ export const mistralProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -287,6 +288,14 @@ export const mistralProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'mistral' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -365,6 +374,7 @@ export const mistralProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -433,7 +443,7 @@ export const mistralProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -454,6 +464,15 @@ export const mistralProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'mistral' } + ) + } + if (request.stream) { logger.info('Using streaming for final response after tool processing') @@ -576,3 +595,8 @@ export const mistralProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 45ea3802b9c..045dd1d462a 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -7,6 +7,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import type { ModelsObject } from '@/providers/ollama/types' import { createReadableStreamFromOllamaStream } from '@/providers/ollama/utils' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -230,7 +231,7 @@ export const ollamaProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -282,7 +283,7 @@ export const ollamaProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -295,6 +296,14 @@ export const ollamaProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'ollama' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -375,6 +384,7 @@ export const ollamaProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -426,7 +436,7 @@ export const ollamaProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -449,6 +459,15 @@ export const ollamaProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'ollama' } + ) + } + if (request.stream) { logger.info('Using streaming for final response after tool processing') @@ -579,3 +598,8 @@ export const ollamaProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 6a0104e0651..1f025269235 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -1,8 +1,9 @@ import type { Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type OpenAI from 'openai' -import type { StreamingExecution } from '@/executor/types' +import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' import { @@ -18,6 +19,7 @@ import { convertResponseOutputToInputItems, convertToolsToResponses, createReadableStreamFromResponses, + extractResponseReasoning, extractResponseText, extractResponseToolCalls, parseResponsesUsage, @@ -347,7 +349,7 @@ export async function executeResponsesProviderRequest( timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -416,7 +418,7 @@ export async function executeResponsesProviderRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -435,6 +437,15 @@ export async function executeResponsesProviderRequest( } const toolCallsInResponse = extractResponseToolCalls(currentResponse.output) + + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + currentResponse, + responseText, + toolCallsInResponse, + { model: request.model } + ) + if (!toolCallsInResponse.length) { break } @@ -511,6 +522,7 @@ export async function executeResponsesProviderRequest( startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: Record @@ -586,7 +598,7 @@ export async function executeResponsesProviderRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -604,6 +616,18 @@ export async function executeResponsesProviderRequest( iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + const trailingText = extractResponseText(currentResponse.output) + const trailingToolCalls = extractResponseToolCalls(currentResponse.output) + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + currentResponse, + trailingText, + trailingToolCalls, + { model: request.model } + ) + } + // For Azure with deferred format: make a final call with the response format applied // This happens whenever we have a deferred format, even if no tools were called // (the initial call was made without the format, so we need to apply it now) @@ -685,6 +709,14 @@ export async function executeResponsesProviderRequest( content = formattedText } + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + currentResponse, + formattedText, + extractResponseToolCalls(currentResponse.output), + { model: request.model } + ) + appliedDeferredFormat = true } @@ -821,3 +853,82 @@ export async function executeResponsesProviderRequest( }) } } + +/** + * Determines a finish reason for an OpenAI Responses API response. + * Maps to conventional values: 'tool_calls' | 'length' | 'stop'. + */ +function deriveOpenAIFinishReason( + response: OpenAI.Responses.Response, + toolCalls: ResponsesToolCall[] +): string | undefined { + const incompleteReason = response.incomplete_details?.reason + if (incompleteReason === 'max_output_tokens') return 'length' + if (incompleteReason === 'content_filter') return 'content_filter' + if (toolCalls.length > 0) return 'tool_calls' + if (incompleteReason) return incompleteReason + if (response.status === 'failed') return 'error' + if (response.status === 'incomplete') return 'length' + if (response.status && response.status !== 'completed') return response.status + return 'stop' +} + +/** + * Enriches the last model segment with per-iteration content extracted from an + * OpenAI Responses API response: assistant text, tool calls, finish reason, + * and token usage for the iteration. + */ +function enrichLastModelSegmentFromOpenAIResponse( + timeSegments: TimeSegment[], + response: OpenAI.Responses.Response, + assistantText: string, + toolCallsInResponse: ResponsesToolCall[], + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const toolCalls: IterationToolCall[] = toolCallsInResponse.map((tc) => ({ + id: tc.id, + name: tc.name, + arguments: + typeof tc.arguments === 'string' ? parseToolCallArguments(tc.arguments) : tc.arguments, + })) + + const usage = parseResponsesUsage(response.usage) + const thinkingContent = extractResponseReasoning(response.output) + + let cost: { input: number; output: number; total: number } | undefined + if (extras?.model && usage) { + const full = calculateCost( + extras.model, + usage.promptTokens, + usage.completionTokens, + usage.cachedTokens > 0 + ) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: deriveOpenAIFinishReason(response, toolCallsInResponse), + tokens: usage + ? { + input: usage.promptTokens, + output: usage.completionTokens, + total: usage.totalTokens, + ...(usage.cachedTokens > 0 && { cacheRead: usage.cachedTokens }), + ...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }), + } + : undefined, + cost, + provider: 'openai', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index f1575473ada..88efec06e2e 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -199,6 +199,29 @@ export function extractResponseText(output: OpenAI.Responses.ResponseOutputItem[ return textParts.join('') } +/** + * Extracts reasoning summary text from Responses API output items. Reasoning + * items (emitted by o1/o3/gpt-5) carry a `summary[]` of `{ type, text }` entries + * — we join the text for trace display. The raw `encrypted_content` is left + * alone; it's opaque plumbing for round-tripping across turns. + */ +export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutputItem[]): string { + if (!Array.isArray(output)) return '' + + const parts: string[] = [] + for (const item of output) { + if (!item || item.type !== 'reasoning') continue + const summary = (item as unknown as { summary?: Array<{ text?: string | null } | null> }) + .summary + if (!Array.isArray(summary)) continue + for (const entry of summary) { + const text = entry?.text + if (typeof text === 'string' && text.length > 0) parts.push(text) + } + } + return parts.join('\n\n') +} + /** * Converts Responses API output items into input items for subsequent calls. */ diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 89ae932c32d..87ff07fcfef 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -10,6 +10,7 @@ import { createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, Message, @@ -210,7 +211,7 @@ export const openRouterProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -258,7 +259,7 @@ export const openRouterProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -280,6 +281,14 @@ export const openRouterProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'openrouter' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -359,6 +368,7 @@ export const openRouterProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -424,7 +434,7 @@ export const openRouterProvider: ProviderConfig = { const thisModelTime = nextModelEndTime - nextModelStartTime timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -441,6 +451,15 @@ export const openRouterProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'openrouter' } + ) + } + if (request.stream) { const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) @@ -573,6 +592,13 @@ export const openRouterProvider: ProviderConfig = { tokens.output += finalResponse.usage.completion_tokens || 0 tokens.total += finalResponse.usage.total_tokens || 0 } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'openrouter' } + ) } const providerEndTime = Date.now() @@ -623,3 +649,8 @@ export const openRouterProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts new file mode 100644 index 00000000000..0d3c3232b28 --- /dev/null +++ b/apps/sim/providers/trace-enrichment.ts @@ -0,0 +1,221 @@ +import type { BlockTokens, IterationToolCall, ProviderTimingSegment } from '@/executor/types' +import { calculateCost } from '@/providers/utils' + +/** + * Minimal structural shape shared by OpenAI Chat Completions and every + * OpenAI-compatible SDK (Groq, Cerebras, DeepSeek, xAI, Mistral, Ollama, + * OpenRouter, vLLM, Fireworks). Captures only the fields the trace enrichment + * helper reads, so providers can pass their own SDK's response type without + * a cast. + */ +interface ChatCompletionLike { + choices: Array<{ + message?: { + content?: string | null + tool_calls?: Array | null + } | null + finish_reason?: string | null + } | null> + usage?: { + prompt_tokens?: number | null + completion_tokens?: number | null + total_tokens?: number | null + prompt_tokens_details?: { cached_tokens?: number | null } | null + completion_tokens_details?: { reasoning_tokens?: number | null } | null + /** DeepSeek's legacy cache shape (not nested under prompt_tokens_details). */ + prompt_cache_hit_tokens?: number | null + } | null +} + +interface ChatCompletionToolCallLike { + id: string + function: { name: string; arguments: string } +} + +/** + * Content to attach to a model segment for a single provider iteration. + * All fields are optional — providers populate what the response carries. + */ +export interface ModelSegmentContent { + assistantContent?: string + thinkingContent?: string + toolCalls?: IterationToolCall[] + finishReason?: string + tokens?: BlockTokens + cost?: { input?: number; output?: number; total?: number } + ttft?: number + provider?: string + errorType?: string + errorMessage?: string +} + +/** + * Enriches the most recent `type: 'model'` segment in `timeSegments` with + * content from the model response for that iteration. Writes only the fields + * provided; undefined fields are skipped so repeat calls can layer data. + * + * Call at the point where the response for the latest model segment is in hand + * — typically right after the provider call returns, before tool execution. + */ +export function enrichLastModelSegment( + timeSegments: ProviderTimingSegment[], + content: ModelSegmentContent +): void { + for (let i = timeSegments.length - 1; i >= 0; i--) { + const segment = timeSegments[i] + if (segment.type !== 'model') continue + + if (content.assistantContent !== undefined) { + segment.assistantContent = content.assistantContent + } + if (content.thinkingContent !== undefined) { + segment.thinkingContent = content.thinkingContent + } + if (content.toolCalls !== undefined) { + segment.toolCalls = content.toolCalls + } + if (content.finishReason !== undefined) { + segment.finishReason = content.finishReason + } + if (content.tokens !== undefined) { + segment.tokens = content.tokens + } + if (content.cost !== undefined) { + segment.cost = content.cost + } + if (content.ttft !== undefined) { + segment.ttft = content.ttft + } + if (content.provider !== undefined) { + segment.provider = content.provider + } + if (content.errorType !== undefined) { + segment.errorType = content.errorType + } + if (content.errorMessage !== undefined) { + segment.errorMessage = content.errorMessage + } + return + } +} + +/** + * Parses a tool call's `function.arguments` JSON string into an object, or + * returns the raw string if it is not valid JSON. + */ +function parseToolCallArguments(rawArguments: string): Record | string { + try { + const parsed = JSON.parse(rawArguments) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + return rawArguments + } catch { + return rawArguments + } +} + +/** + * Extracts reasoning/thinking content from a Chat Completions message. Covers + * non-OpenAI extensions emitted by reasoning-capable providers: + * - `reasoning_content`: DeepSeek, xAI, vLLM, Fireworks + * - `reasoning`: Groq, Cerebras, OpenRouter (flat) + * - `reasoning_details[]`: OpenRouter (structured per-block reasoning) + */ +function extractChatCompletionsReasoning( + message: NonNullable['message'] +): string | undefined { + if (!message) return undefined + const msg = message as unknown as { + reasoning_content?: string | null + reasoning?: string | null + reasoning_details?: Array<{ text?: string | null; summary?: string | null } | null> | null + } + + if (typeof msg.reasoning_content === 'string' && msg.reasoning_content.length > 0) { + return msg.reasoning_content + } + if (typeof msg.reasoning === 'string' && msg.reasoning.length > 0) { + return msg.reasoning + } + if (Array.isArray(msg.reasoning_details)) { + const joined = msg.reasoning_details + .map((d) => d?.text ?? d?.summary ?? '') + .filter((s): s is string => typeof s === 'string' && s.length > 0) + .join('\n') + if (joined.length > 0) return joined + } + return undefined +} + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, thinking/reasoning, tool calls, finish + * reason, token usage. Shared by all OpenAI-compat providers. + */ +export function enrichLastModelSegmentFromChatCompletions( + timeSegments: ProviderTimingSegment[], + response: ChatCompletionLike, + toolCallsInResponse: ChatCompletionToolCallLike[] | undefined, + extras?: { + /** Model id used for this call — enables automatic cost calculation. */ + model?: string + /** Provider system identifier (`gen_ai.system`). */ + provider?: string + /** Time-to-first-token in ms (streaming path only). */ + ttft?: number + /** Structured error class when the call failed. */ + errorType?: string + /** Human-readable error message when the call failed. */ + errorMessage?: string + /** Override the automatically derived cost. */ + cost?: { input?: number; output?: number; total?: number } + } +): void { + const choice = response.choices[0] + const assistantText = choice?.message?.content ?? '' + const thinkingText = extractChatCompletionsReasoning(choice?.message) + + const toolCalls: IterationToolCall[] = (toolCallsInResponse ?? []).map((tc) => ({ + id: tc.id, + name: tc.function.name, + arguments: parseToolCallArguments(tc.function.arguments), + })) + + const usage = response.usage + const cacheRead = + usage?.prompt_tokens_details?.cached_tokens ?? usage?.prompt_cache_hit_tokens ?? 0 + const reasoning = usage?.completion_tokens_details?.reasoning_tokens ?? 0 + + const promptTokens = usage?.prompt_tokens ?? undefined + const completionTokens = usage?.completion_tokens ?? undefined + + let derivedCost = extras?.cost + if (!derivedCost && extras?.model && promptTokens != null && completionTokens != null) { + const full = calculateCost(extras.model, promptTokens, completionTokens, cacheRead > 0) + derivedCost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingText, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: choice?.finish_reason ?? undefined, + tokens: usage + ? { + input: promptTokens, + output: completionTokens, + total: usage.total_tokens ?? undefined, + ...(cacheRead > 0 && { cacheRead }), + ...(reasoning > 0 && { reasoning }), + } + : undefined, + cost: derivedCost, + ttft: extras?.ttft, + provider: extras?.provider, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} + +export { parseToolCallArguments } diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 69c36079df7..468d0f8bdaa 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -1,4 +1,4 @@ -import type { StreamingExecution } from '@/executor/types' +import type { ProviderTimingSegment, StreamingExecution } from '@/executor/types' export type ProviderId = | 'openai' @@ -63,13 +63,12 @@ export interface FunctionCallResponse { success?: boolean } -export interface TimeSegment { - type: 'model' | 'tool' - name: string - startTime: number - endTime: number - duration: number -} +/** + * Provider-side alias for the canonical segment type. Providers push these into + * `providerTiming.timeSegments` during execution; the trace pipeline reads them + * verbatim when constructing child spans. + */ +export type TimeSegment = ProviderTimingSegment export interface ProviderResponse { content: string diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 66027c43f96..db25ba45ec0 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -6,6 +6,7 @@ import { env } from '@/lib/core/config/env' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderConfig, @@ -252,7 +253,7 @@ export const vllmProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -329,7 +330,7 @@ export const vllmProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -347,6 +348,14 @@ export const vllmProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'vllm' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -427,6 +436,7 @@ export const vllmProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -495,7 +505,7 @@ export const vllmProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -519,6 +529,15 @@ export const vllmProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'vllm' } + ) + } + if (request.stream) { logger.info('Using streaming for final response after tool processing') @@ -662,3 +681,8 @@ export const vllmProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index fdbed7f5c47..309a9fd8f3b 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -5,6 +5,7 @@ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderConfig, @@ -156,7 +157,7 @@ export const xAIProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -227,7 +228,7 @@ export const xAIProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -251,6 +252,14 @@ export const xAIProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'xai' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -331,6 +340,7 @@ export const xAIProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any if (result.success && result.output) { @@ -441,7 +451,7 @@ export const xAIProvider: ProviderConfig = { const thisModelTime = nextModelEndTime - nextModelStartTime timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -461,6 +471,15 @@ export const xAIProvider: ProviderConfig = { iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'xai' } + ) + } } catch (error) { logger.error('XAI Provider - Error in tool processing loop:', { error: toError(error).message, @@ -614,3 +633,8 @@ export const xAIProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index bdd103f16e9..3fbd85bfaee 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -1,3 +1,7 @@ +import type { ProviderTiming, TokenInfo, ToolCall, TraceSpan } from '@/lib/logs/types' + +export type { ProviderTiming, TokenInfo, ToolCall, TraceSpan } + export interface WorkflowData { id: string name: string @@ -6,17 +10,6 @@ export interface WorkflowData { state: any } -export interface ToolCall { - name: string - duration: number // in milliseconds - startTime: string // ISO timestamp - endTime: string // ISO timestamp - status: 'success' | 'error' // Status of the tool call - input?: Record // Input parameters (optional) - output?: Record // Output data (optional) - error?: string // Error message if status is 'error' -} - export interface ToolCallMetadata { toolCalls?: ToolCall[] } @@ -55,52 +48,6 @@ export interface CostMetadata { } } -export interface TokenInfo { - input?: number - output?: number - total?: number - prompt?: number - completion?: number -} - -export interface ProviderTiming { - duration: number - startTime: string - endTime: string - segments: Array<{ - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }> -} - -export interface TraceSpan { - id: string - name: string - type: string - duration: number // in milliseconds - startTime: string - endTime: string - children?: TraceSpan[] - toolCalls?: ToolCall[] - status?: 'success' | 'error' - errorHandled?: boolean - tokens?: number | TokenInfo - relativeStartMs?: number // Time in ms from the start of the parent span - blockId?: string // Added to track the original block ID for relationship mapping - input?: Record // Added to store input data for this span - output?: Record // Added to store output data for this span - model?: string - cost?: { - input?: number - output?: number - total?: number - } - providerTiming?: ProviderTiming -} - export interface WorkflowLog { id: string workflowId: string | null From 1732ba47f0ba9cd39df219f21024a19ff48d003d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Apr 2026 14:29:41 -0700 Subject: [PATCH 02/28] improvement(logs): add Trace tab with two-pane tree+detail view - Wrap log-details drawer in Overview | Trace tabs; Overview unchanged - New TraceView with hierarchical tree on the left and detail pane on the right - Keyboard nav, span filter, expand/collapse all - Bump min drawer width 400->600 and clamp persisted widths on rehydrate Co-Authored-By: Claude Opus 4.7 --- .../[workspaceId]/logs/components/index.ts | 1 + .../components/trace-view/index.ts | 1 + .../components/trace-view/trace-view.tsx | 978 ++++++++++++++++++ .../components/log-details/log-details.tsx | 492 +++++---- apps/sim/stores/logs/store.ts | 5 + apps/sim/stores/logs/utils.ts | 4 +- 6 files changed, 1254 insertions(+), 227 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts index 3c0c5922adf..59310eeb4e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts @@ -3,6 +3,7 @@ export { LogDetails, WorkflowOutputSection } from './log-details' export { ExecutionSnapshot } from './log-details/components/execution-snapshot' export { FileCards } from './log-details/components/file-download' export { TraceSpans } from './log-details/components/trace-spans' +export { TraceView } from './log-details/components/trace-view' export { LogRowContextMenu } from './log-row-context-menu' export { LogsList } from './logs-list' export { AutocompleteSearch, LogsToolbar, NotificationSettings } from './logs-toolbar' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/index.ts new file mode 100644 index 00000000000..b90734e6ab1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/index.ts @@ -0,0 +1 @@ +export { TraceView } from './trace-view' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx new file mode 100644 index 00000000000..a7bc7433120 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -0,0 +1,978 @@ +'use client' + +import type React from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ArrowDown, + ArrowUp, + Check, + ChevronsDownUp, + ChevronsUpDown, + Clipboard, + Search, + X, +} from 'lucide-react' +import { createPortal } from 'react-dom' +import { + Button, + ChevronDown, + Code, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + Tooltip, +} from '@/components/emcn' +import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' +import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons' +import { cn } from '@/lib/core/utils/cn' +import { formatDuration } from '@/lib/core/utils/formatting' +import type { TraceSpan } from '@/lib/logs/types' +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' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' + +const DEFAULT_BLOCK_COLOR = '#6b7280' +const TREE_PANE_WIDTH = 240 +const INDENT_PX = 12 + +interface TraceViewProps { + traceSpans: TraceSpan[] +} + +interface FlatSpanEntry { + span: TraceSpan + depth: number + parentIds: string[] +} + +interface BlockAppearance { + icon: React.ComponentType<{ className?: string }> | null + bgColor: string +} + +/** + * Parses a timestamp or numeric ms into milliseconds since epoch. + */ +function parseTime(value?: string | number | null): number { + if (!value) return 0 + const ms = typeof value === 'number' ? value : new Date(value).getTime() + return Number.isFinite(ms) ? ms : 0 +} + +/** + * Whether a span type represents a loop or parallel iteration container. + */ +function isIterationType(type: string): boolean { + const lower = type?.toLowerCase() || '' + return lower === 'loop-iteration' || lower === 'parallel-iteration' +} + +/** + * Returns the stable id for a span, synthesized when absent. + */ +function getSpanId(span: TraceSpan): string { + return span.id || `span-${span.name}-${span.startTime}` +} + +/** + * Walks a span's descendants to determine if any error exists in the subtree. + */ +function hasErrorInTree(span: TraceSpan): boolean { + if (span.status === 'error') return true + if (span.children?.length) return span.children.some(hasErrorInTree) + if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error) + return false +} + +/** + * Like `hasErrorInTree` but only counts errors that were not handled by an + * error-handler path. Used for the root workflow status color. + */ +function hasUnhandledErrorInTree(span: TraceSpan): boolean { + if (span.status === 'error' && !span.errorHandled) return true + if (span.children?.length) return span.children.some(hasUnhandledErrorInTree) + if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error) + return false +} + +/** + * Normalizes and sorts a tree of spans by start time. + */ +function normalizeAndSort(spans: TraceSpan[]): TraceSpan[] { + return spans + .map((span) => ({ + ...span, + children: span.children?.length ? normalizeAndSort(span.children) : undefined, + })) + .sort((a, b) => { + const d = parseTime(a.startTime) - parseTime(b.startTime) + return d !== 0 ? d : parseTime(a.endTime) - parseTime(b.endTime) + }) +} + +/** + * For agents with no tool calls, hides synthetic model-segment children to + * avoid noise in the tree. + */ +function getDisplayChildren(span: TraceSpan): TraceSpan[] { + const kids: TraceSpan[] = span.children?.length + ? [...span.children] + : (span.toolCalls ?? []).map((tc, i) => ({ + id: `${getSpanId(span)}-tool-${i}`, + name: tc.name, + type: 'tool', + duration: tc.duration || 0, + startTime: tc.startTime ?? span.startTime, + endTime: tc.endTime ?? span.endTime, + status: tc.error ? ('error' as const) : ('success' as const), + input: tc.input, + output: tc.error ? { error: tc.error, ...(tc.output ?? {}) } : tc.output, + })) + kids.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) + const isAgent = span.type?.toLowerCase() === 'agent' + const hasToolCall = kids.some((c) => c.type?.toLowerCase() === 'tool') + if (isAgent && !hasToolCall) return kids.filter((c) => c.type?.toLowerCase() !== 'model') + return kids +} + +/** + * Resolves the block icon and accent color for a trace span type. + */ +function getBlockAppearance(type: string, toolName?: string): BlockAppearance { + const lowerType = type.toLowerCase() + if (lowerType === 'tool' && toolName) { + if (toolName === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } + const toolBlock = getBlockByToolName(toolName) + if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor } + } + if (lowerType === 'loop' || lowerType === 'loop-iteration') + return { icon: LoopTool.icon, bgColor: LoopTool.bgColor } + if (lowerType === 'parallel' || lowerType === 'parallel-iteration') + return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor } + if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' } + const blockType = lowerType === 'model' ? 'agent' : lowerType + const blockConfig = getBlock(blockType) + if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor } + return { icon: null, bgColor: DEFAULT_BLOCK_COLOR } +} + +function formatTokenCount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + return value.toLocaleString('en-US') +} + +function formatCostAmount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + if (value < 0.0001) return '<$0.0001' + return `$${value.toFixed(4)}` +} + +function formatTtft(ms: number | undefined): string | undefined { + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined { + if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined + if (!(durationMs > 0)) return undefined + const tps = Math.round(outputTokens / (durationMs / 1000)) + return tps > 0 ? `${tps.toLocaleString('en-US')} tok/s` : undefined +} + +/** + * Flattens the visible (expanded) span tree into a linear list for keyboard + * navigation, carrying depth and the chain of parent ids for indent drawing. + */ +function flattenVisible(spans: TraceSpan[], expanded: Set): FlatSpanEntry[] { + const out: FlatSpanEntry[] = [] + const walk = (list: TraceSpan[], depth: number, parents: string[]) => { + for (const span of list) { + const id = getSpanId(span) + out.push({ span, depth, parentIds: parents }) + const children = getDisplayChildren(span) + if (children.length > 0 && expanded.has(id)) { + walk(children, depth + 1, [...parents, id]) + } + } + } + walk(spans, 0, []) + return out +} + +/** + * Returns every descendant span id in the tree. + */ +function collectAllIds(spans: TraceSpan[]): string[] { + const out: string[] = [] + const walk = (list: TraceSpan[]) => { + for (const span of list) { + out.push(getSpanId(span)) + const children = getDisplayChildren(span) + if (children.length > 0) walk(children) + } + } + walk(spans) + return out +} + +/** + * Finds a span by id anywhere in the tree. + */ +function findSpan(spans: TraceSpan[], id: string | null): TraceSpan | null { + if (!id) return null + for (const span of spans) { + if (getSpanId(span) === id) return span + const children = getDisplayChildren(span) + if (children.length > 0) { + const found = findSpan(children, id) + if (found) return found + } + } + return null +} + +/** + * Case-insensitive name match. + */ +function spanMatchesQuery(span: TraceSpan, query: string): boolean { + if (!query) return true + return (span.name ?? '').toLowerCase().includes(query.toLowerCase()) +} + +/** + * Returns the set of ids of spans that match the query themselves or contain + * a matching descendant. Used to show only relevant branches while preserving + * their parents. + */ +function collectMatchingIds(spans: TraceSpan[], query: string): Set { + const matches = new Set() + const walk = (list: TraceSpan[]): boolean => { + let anyMatch = false + for (const span of list) { + const id = getSpanId(span) + const children = getDisplayChildren(span) + const childMatch = children.length > 0 ? walk(children) : false + const selfMatch = spanMatchesQuery(span, query) + if (selfMatch || childMatch) { + matches.add(id) + anyMatch = true + } + } + return anyMatch + } + walk(spans) + return matches +} + +/** + * Row in the tree pane. Renders the span icon, name, duration, and indentation + * guides. Clicking selects the span; the chevron toggles expansion. + */ +const TraceTreeRow = memo(function TraceTreeRow({ + entry, + isSelected, + isExpanded, + canExpand, + onSelect, + onToggleExpand, + matchQuery, +}: { + entry: FlatSpanEntry + isSelected: boolean + isExpanded: boolean + canExpand: boolean + onSelect: (id: string) => void + onToggleExpand: (id: string) => void + matchQuery: string +}) { + const { span, depth } = entry + const id = getSpanId(span) + const duration = span.duration || parseTime(span.endTime) - parseTime(span.startTime) + const isRootWorkflow = depth === 0 && span.type?.toLowerCase() === 'workflow' + const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span) + const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name) + const nameMatches = !!matchQuery && spanMatchesQuery(span, matchQuery) + + return ( +
onSelect(id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(id) + } + }} + role='treeitem' + tabIndex={isSelected ? 0 : -1} + aria-selected={isSelected} + aria-expanded={canExpand ? isExpanded : undefined} + aria-level={depth + 1} + > + {canExpand ? ( + + ) : ( +
+ )} + {!isIterationType(span.type) && ( +
+ {BlockIcon && } +
+ )} + + {span.name} + + + {formatDuration(duration, { precision: 2 })} + +
+ ) +}) + +/** + * Collapsible code viewer with copy/search overlay, used for input/output/thinking/ + * tool-call/error blobs in the detail pane. + */ +function DetailCodeSection({ + label, + data, + isError, + defaultOpen = true, +}: { + label: string + data: unknown + isError?: boolean + defaultOpen?: boolean +}) { + const [isOpen, setIsOpen] = useState(defaultOpen) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + const [copied, setCopied] = useState(false) + const contentRef = useRef(null) + + const { + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) + + const jsonString = useMemo(() => { + if (data == null) return '' + if (typeof data === 'string') return data + return JSON.stringify(data, null, 2) + }, [data]) + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setIsContextMenuOpen(true) + }, []) + + const closeContextMenu = useCallback(() => setIsContextMenuOpen(false), []) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(jsonString) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + closeContextMenu() + }, [jsonString, closeContextMenu]) + + const handleSearch = useCallback(() => { + activateSearch() + closeContextMenu() + }, [activateSearch, closeContextMenu]) + + return ( +
+
setIsOpen((v) => !v)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setIsOpen((v) => !v) + } + }} + role='button' + tabIndex={0} + aria-expanded={isOpen} + > + + {label} + + +
+ {isOpen && ( + <> +
+ + {!isSearchActive && ( +
+ + + + + {copied ? 'Copied' : 'Copy'} + + + + + + Search + +
+ )} +
+ {isSearchActive && ( +
e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-0.5 h-[23px] w-[94px] text-caption' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + + +
+ )} + {typeof document !== 'undefined' && + createPortal( + + +
+ + e.preventDefault()} + > + + + Copy + + + + + Search + + + , + document.body + )} + + )} +
+ ) +} + +/** + * A single label:value row in the metadata block of the detail pane. + */ +function MetaRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +/** + * Right-side pane. Renders a header and the available content sections for + * the selected span: metadata, input, output, thinking, tool calls, error. + */ +const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpan | null }) { + if (!span) { + return ( +
+ Select a span to see details. +
+ ) + } + + const duration = span.duration || parseTime(span.endTime) - parseTime(span.startTime) + const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name) + const isRootWorkflow = span.type?.toLowerCase() === 'workflow' + const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span) + const isDirectError = span.status === 'error' + const isModelSpan = span.type?.toLowerCase() === 'model' + + const startedAt = parseTime(span.startTime) + const endedAt = parseTime(span.endTime) + + const metaEntries: { label: string; value: string }[] = [] + metaEntries.push({ label: 'Type', value: span.type }) + metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' }) + if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider }) + if (span.model) metaEntries.push({ label: 'Model', value: span.model }) + if (span.finishReason) metaEntries.push({ label: 'Finish reason', value: span.finishReason }) + const ttftFormatted = formatTtft(span.ttft) + if (ttftFormatted) metaEntries.push({ label: 'TTFT', value: ttftFormatted }) + const tpsFormatted = isModelSpan ? formatTps(span.tokens?.output, duration) : undefined + if (tpsFormatted) metaEntries.push({ label: 'Throughput', value: tpsFormatted }) + const inputTokens = formatTokenCount(span.tokens?.input) + const outputTokens = formatTokenCount(span.tokens?.output) + const totalTokens = formatTokenCount(span.tokens?.total) + const cacheRead = formatTokenCount(span.tokens?.cacheRead) + const cacheWrite = formatTokenCount(span.tokens?.cacheWrite) + const reasoning = formatTokenCount(span.tokens?.reasoning) + if (inputTokens) metaEntries.push({ label: 'Input tokens', value: inputTokens }) + if (outputTokens) metaEntries.push({ label: 'Output tokens', value: outputTokens }) + if (totalTokens) metaEntries.push({ label: 'Total tokens', value: totalTokens }) + if (cacheRead) metaEntries.push({ label: 'Cache read', value: cacheRead }) + if (cacheWrite) metaEntries.push({ label: 'Cache write', value: cacheWrite }) + if (reasoning) metaEntries.push({ label: 'Reasoning tokens', value: reasoning }) + const costTotal = formatCostAmount(span.cost?.total) + const costInput = formatCostAmount(span.cost?.input) + const costOutput = formatCostAmount(span.cost?.output) + if (costTotal) metaEntries.push({ label: 'Cost', value: costTotal }) + if (costInput) metaEntries.push({ label: 'Cost input', value: costInput }) + if (costOutput) metaEntries.push({ label: 'Cost output', value: costOutput }) + if (span.errorType) metaEntries.push({ label: 'Error type', value: span.errorType }) + if (span.iterationIndex !== undefined) + metaEntries.push({ label: 'Iteration', value: String(span.iterationIndex + 1) }) + + const statusLabel = hasError ? 'Error' : 'Success' + + return ( +
+ {/* Header */} +
+ {!isIterationType(span.type) && ( +
+ {BlockIcon && } +
+ )} +
+

+ {span.name} +

+
+ + {statusLabel} + + · + {formatDuration(duration, { precision: 2 }) || '—'} + {Number.isFinite(startedAt) && startedAt > 0 && ( + <> + · + + {new Date(startedAt).toLocaleTimeString()} + + + )} +
+
+
+ + {/* Metadata block */} + {metaEntries.length > 0 && ( +
+ {metaEntries.map((m) => ( + + ))} +
+ )} + + {/* Content sections */} + {span.input !== undefined && span.input !== null && ( + + )} + {span.output !== undefined && span.output !== null && ( + + )} + {span.thinking && } + {span.modelToolCalls && span.modelToolCalls.length > 0 && ( + + )} + {span.errorMessage && ( + + )} + + {/* Raw timing footer */} + {Number.isFinite(startedAt) && Number.isFinite(endedAt) && startedAt > 0 && endedAt > 0 && ( +
+ Started {new Date(startedAt).toISOString()} + Ended {new Date(endedAt).toISOString()} +
+ )} +
+ ) +}) + +/** + * Rich two-pane trace view: hierarchical span tree on the left with + * keyboard-navigable selection, detail pane on the right. Renders the run + * in a way that mirrors the executor's internal structure so investigators can + * follow block-by-block and segment-by-segment what happened and why. + */ +export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) { + const containerRef = useRef(null) + const [searchQuery, setSearchQuery] = useState('') + const [expandedNodes, setExpandedNodes] = useState>(() => new Set()) + const [hasInitialized, setHasInitialized] = useState(false) + const [selectedId, setSelectedId] = useState(null) + + const { normalizedSpans, allIds, totalDuration, firstRootId, blockCount } = useMemo(() => { + const sorted = normalizeAndSort(traceSpans ?? []) + let earliest = Number.POSITIVE_INFINITY + let latest = 0 + for (const span of sorted) { + const s = parseTime(span.startTime) + const e = parseTime(span.endTime) + if (s < earliest) earliest = s + if (e > latest) latest = e + } + const ids = collectAllIds(sorted) + const count = ids.length + return { + normalizedSpans: sorted, + allIds: ids, + totalDuration: latest > earliest ? latest - earliest : 0, + firstRootId: sorted.length > 0 ? getSpanId(sorted[0]) : null, + blockCount: count, + } + }, [traceSpans]) + + useEffect(() => { + setExpandedNodes(new Set(allIds)) + setSelectedId(firstRootId) + setHasInitialized(true) + }, [allIds, firstRootId]) + + const matchingIds = useMemo( + () => (searchQuery ? collectMatchingIds(normalizedSpans, searchQuery) : null), + [normalizedSpans, searchQuery] + ) + + const flatList = useMemo(() => { + const visible = flattenVisible(normalizedSpans, expandedNodes) + if (!matchingIds) return visible + return visible.filter((entry) => matchingIds.has(getSpanId(entry.span))) + }, [normalizedSpans, expandedNodes, matchingIds]) + + const selectedSpan = useMemo( + () => findSpan(normalizedSpans, selectedId), + [normalizedSpans, selectedId] + ) + + const runStatus = useMemo(() => { + if (normalizedSpans.length === 0) return 'empty' as const + const rootHasError = normalizedSpans.some((span) => + span.type?.toLowerCase() === 'workflow' ? hasUnhandledErrorInTree(span) : hasErrorInTree(span) + ) + return rootHasError ? ('error' as const) : ('success' as const) + }, [normalizedSpans]) + + const handleSelect = useCallback((id: string) => setSelectedId(id), []) + + const handleToggleExpand = useCallback((id: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const handleExpandAll = useCallback(() => setExpandedNodes(new Set(allIds)), [allIds]) + const handleCollapseAll = useCallback(() => setExpandedNodes(new Set()), []) + + useEffect(() => { + const container = containerRef.current + if (!container) return + const handler = (e: KeyboardEvent) => { + if (!container.contains(document.activeElement)) return + if (!selectedId) return + const currentIndex = flatList.findIndex((entry) => getSpanId(entry.span) === selectedId) + if (currentIndex === -1) return + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = flatList[Math.min(flatList.length - 1, currentIndex + 1)] + if (next) setSelectedId(getSpanId(next.span)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const prev = flatList[Math.max(0, currentIndex - 1)] + if (prev) setSelectedId(getSpanId(prev.span)) + } else if (e.key === 'ArrowLeft') { + const entry = flatList[currentIndex] + const span = entry.span + const id = getSpanId(span) + const canExpand = getDisplayChildren(span).length > 0 + if (canExpand && expandedNodes.has(id)) { + e.preventDefault() + handleToggleExpand(id) + } else if (entry.parentIds.length > 0) { + e.preventDefault() + const parentId = entry.parentIds[entry.parentIds.length - 1] + setSelectedId(parentId) + } + } else if (e.key === 'ArrowRight') { + const entry = flatList[currentIndex] + const span = entry.span + const id = getSpanId(span) + const canExpand = getDisplayChildren(span).length > 0 + if (canExpand && !expandedNodes.has(id)) { + e.preventDefault() + handleToggleExpand(id) + } + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [flatList, selectedId, expandedNodes, handleToggleExpand]) + + if (!traceSpans || traceSpans.length === 0) { + return ( +
+ No trace data available +
+ ) + } + + return ( +
+ {/* Header strip */} +
+ + {runStatus === 'error' ? 'Error' : 'Success'} + + + {formatDuration(totalDuration, { precision: 2 }) || '—'} + + + {blockCount} {blockCount === 1 ? 'span' : 'spans'} + +
+
+ + setSearchQuery(e.target.value)} + placeholder='Filter spans' + className='h-[24px] w-[140px] pl-[22px] text-caption' + /> +
+ + + + + Expand all + + + + + + Collapse all + +
+
+ + {/* Tree + detail split */} +
+
+ {hasInitialized && flatList.length === 0 && ( +
No matching spans
+ )} + {flatList.map((entry) => { + const id = getSpanId(entry.span) + const canExpand = getDisplayChildren(entry.span).length > 0 + return ( + + ) + })} +
+
+ +
+
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 42e09dd0385..5e854cf3ea8 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -14,6 +14,10 @@ import { DropdownMenuTrigger, Eye, Input, + SModalTabs, + SModalTabsContent, + SModalTabsList, + SModalTabsTrigger, Tooltip, } from '@/components/emcn' import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' @@ -25,6 +29,7 @@ import { ExecutionSnapshot, FileCards, TraceSpans, + TraceView, } from '@/app/workspace/[workspaceId]/logs/components' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import { @@ -272,6 +277,8 @@ interface LogDetailsProps { * @param props - Component props * @returns Log details sidebar component */ +type LogDetailsTab = 'overview' | 'trace' + export const LogDetails = memo(function LogDetails({ log, isOpen, @@ -282,6 +289,7 @@ export const LogDetails = memo(function LogDetails({ hasPrev = false, }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) + const [activeTab, setActiveTab] = useState('overview') const scrollAreaRef = useRef(null) const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) const { handleMouseDown } = useLogDetailsResize() @@ -301,7 +309,13 @@ export const LogDetails = memo(function LogDetails({ ((log.trigger === 'manual' && !!log.duration) || !!(log.executionData?.enhanced && log.executionData?.traceSpans)) - const hasCostInfo = isWorkflowExecutionLog && log?.cost + const hasCostInfo = !!(isWorkflowExecutionLog && log?.cost) + const showTraceTab = + isWorkflowExecutionLog && !!log?.executionData?.traceSpans && !permissionConfig.hideTraceSpans + + useEffect(() => { + if (activeTab === 'trace' && !showTraceTab) setActiveTab('overview') + }, [activeTab, showTraceTab]) const workflowOutput = useMemo(() => { const executionData = log?.executionData as @@ -389,262 +403,290 @@ export const LogDetails = memo(function LogDetails({
- {/* Content - Scrollable */} -
-
- {/* Timestamp & Workflow Row */} -
- {/* Timestamp Card */} -
-
- Timestamp + {/* Tabs */} + setActiveTab(v as LogDetailsTab)} + className='mt-4 flex min-h-0 flex-1 flex-col' + > + + Overview + {showTraceTab && Trace} + + + {/* Overview Tab */} + +
+ {/* Timestamp & Workflow Row */} +
+ {/* Timestamp Card */} +
+
+ Timestamp +
+
+ + {formattedTimestamp?.compactDate || 'N/A'} + + + {formattedTimestamp?.compactTime || 'N/A'} + +
-
- - {formattedTimestamp?.compactDate || 'N/A'} - - - {formattedTimestamp?.compactTime || 'N/A'} - + + {/* Workflow Card */} +
+
+ {log.trigger === 'mothership' ? 'Job' : 'Workflow'} +
+
+ {(() => { + const c = + log.trigger === 'mothership' + ? '#ec4899' + : log.workflow?.color || + (!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined) + return ( +
+ ) + })()} + + {log.trigger === 'mothership' + ? log.jobTitle || 'Untitled Job' + : log.workflow?.name || + (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} + +
- {/* Workflow Card */} -
-
- {log.trigger === 'mothership' ? 'Job' : 'Workflow'} -
-
- {(() => { - const c = - log.trigger === 'mothership' - ? '#ec4899' - : log.workflow?.color || - (!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined) - return ( -
- ) - })()} - - {log.trigger === 'mothership' - ? log.jobTitle || 'Untitled Job' - : log.workflow?.name || - (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} + {/* Run ID */} + {log.executionId && ( +
+ + Run ID + + + {log.executionId}
-
-
- - {/* Run ID */} - {log.executionId && ( -
- - Run ID - - - {log.executionId} - -
- )} - - {/* Details Section */} -
- {/* Level */} -
- - Level - - -
+ )} - {/* Trigger */} -
- - Trigger - - {log.trigger ? ( - - ) : ( - - — + {/* Details Section */} +
+ {/* Level */} +
+ + Level - )} -
- - {/* Duration */} -
- - Duration - - - {formatDuration(log.duration, { precision: 2 }) || '—'} - -
+ +
- {/* Version */} - {log.deploymentVersion && ( -
- - Version + {/* Trigger */} +
+ + Trigger -
- - {log.deploymentVersionName || `v${log.deploymentVersion}`} + {log.trigger ? ( + + ) : ( + + — -
+ )}
- )} -
- {/* Workflow State */} - {isWorkflowExecutionLog && - log.executionId && - log.trigger !== 'mothership' && - !permissionConfig.hideTraceSpans && ( -
+ {/* Duration */} +
- Workflow State + Duration + + + {formatDuration(log.duration, { precision: 2 }) || '—'} -
- )} - {/* Workflow Output */} - {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( -
- - Workflow Output - - + {/* Version */} + {log.deploymentVersion && ( +
+ + Version + +
+ + {log.deploymentVersionName || `v${log.deploymentVersion}`} + +
+
+ )}
- )} - {/* Workflow Execution - Trace Spans */} - {isWorkflowExecutionLog && - log.executionData?.traceSpans && - !permissionConfig.hideTraceSpans && ( + {/* Workflow State */} + {isWorkflowExecutionLog && + log.executionId && + log.trigger !== 'mothership' && + !permissionConfig.hideTraceSpans && ( +
+ + Workflow State + + +
+ )} + + {/* Workflow Output */} + {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
- - Trace Span + + Workflow Output - +
)} - {/* Files */} - {log.files && log.files.length > 0 && ( - - )} - - {/* Cost Breakdown */} - {hasCostInfo && ( -
- - Cost Breakdown - - -
-
-
- - Base Run: - - - {formatCost(BASE_EXECUTION_CHARGE)} - -
-
- - Model Input: - - - {formatCost(log.cost?.input || 0)} - -
-
- - Model Output: - - - {formatCost(log.cost?.output || 0)} - -
- {(() => { - const models = (log.cost as Record)?.models as - | Record - | undefined - const totalToolCost = models - ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) - : 0 - return totalToolCost > 0 ? ( -
- - Tool Usage: - - - {formatCost(totalToolCost)} - -
- ) : null - })()} + {/* Workflow Execution - Trace Spans */} + {isWorkflowExecutionLog && + log.executionData?.traceSpans && + !permissionConfig.hideTraceSpans && ( +
+ + Trace Span + +
+ )} -
+ {/* Files */} + {log.files && log.files.length > 0 && ( + + )} -
-
- - Total: - - - {formatCost(log.cost?.total || 0)} - + {/* Cost Breakdown */} + {hasCostInfo && ( +
+ + Cost Breakdown + + +
+
+
+ + Base Run: + + + {formatCost(BASE_EXECUTION_CHARGE)} + +
+
+ + Model Input: + + + {formatCost(log.cost?.input || 0)} + +
+
+ + Model Output: + + + {formatCost(log.cost?.output || 0)} + +
+ {(() => { + const models = (log.cost as Record)?.models as + | Record + | undefined + const totalToolCost = models + ? Object.values(models).reduce( + (sum, m) => sum + (m?.toolCost || 0), + 0 + ) + : 0 + return totalToolCost > 0 ? ( +
+ + Tool Usage: + + + {formatCost(totalToolCost)} + +
+ ) : null + })()}
-
- - Tokens: - - - {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} - {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out - + +
+ +
+
+ + Total: + + + {formatCost(log.cost?.total || 0)} + +
+
+ + Tokens: + + + {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} + {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out + +
-
-
-

- Total cost includes a base run charge of {formatCost(BASE_EXECUTION_CHARGE)}{' '} - plus any model and tool usage costs. -

+
+

+ Total cost includes a base run charge of{' '} + {formatCost(BASE_EXECUTION_CHARGE)} plus any model and tool usage costs. +

+
-
- )} -
-
+ )} +
+ + + {/* Trace Tab */} + {showTraceTab && log.executionData?.traceSpans && ( + + + + )} +
)} diff --git a/apps/sim/stores/logs/store.ts b/apps/sim/stores/logs/store.ts index f9e0361e2c8..0360e7e7bb4 100644 --- a/apps/sim/stores/logs/store.ts +++ b/apps/sim/stores/logs/store.ts @@ -26,6 +26,11 @@ export const useLogDetailsUIStore = create()( { name: 'log-details-ui-state', partialize: (state) => ({ panelWidth: state.panelWidth }), + onRehydrateStorage: () => (state) => { + if (state) { + state.panelWidth = clampPanelWidth(state.panelWidth) + } + }, } ) ) diff --git a/apps/sim/stores/logs/utils.ts b/apps/sim/stores/logs/utils.ts index 4b5d043d1d3..778320066c0 100644 --- a/apps/sim/stores/logs/utils.ts +++ b/apps/sim/stores/logs/utils.ts @@ -1,8 +1,8 @@ /** * Width constraints for the log details panel. */ -export const MIN_LOG_DETAILS_WIDTH = 400 -export const DEFAULT_LOG_DETAILS_WIDTH = 400 +export const MIN_LOG_DETAILS_WIDTH = 600 +export const DEFAULT_LOG_DETAILS_WIDTH = 600 export const MAX_LOG_DETAILS_WIDTH_RATIO = 0.65 /** From 38d730b4267e6459dff67d2d1f84b1ec0fe14f8d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Apr 2026 15:00:20 -0700 Subject: [PATCH 03/28] feat(logs): retry failed runs + show workflow input in detail Brings PR #4181 inline: persists workflowInput on successful runs, adds useRetryExecution mutation (streaming read-one-chunk-and-cancel), Retry entrypoints in the row context menu and the detail sidebar, and extractRetryInput with fallback to starter block state for older logs. Also surfaces the captured input in a new "Workflow Input" section above Workflow Output in the detail Overview tab, guarded so older logs without the field don't render an empty block. Co-Authored-By: Claude Opus 4.7 --- .../components/trace-view/trace-view.tsx | 231 ++++++++++++------ .../components/log-details/log-details.tsx | 52 +++- .../log-row-context-menu.tsx | 16 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 43 ++++ .../app/workspace/[workspaceId]/logs/utils.ts | 35 +++ apps/sim/hooks/queries/logs.ts | 33 ++- apps/sim/lib/logs/execution/logger.ts | 4 + apps/sim/lib/logs/types.ts | 1 + 8 files changed, 327 insertions(+), 88 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index a7bc7433120..e210d239050 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -36,8 +36,9 @@ import { getBlock, getBlockByToolName } from '@/blocks' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' const DEFAULT_BLOCK_COLOR = '#6b7280' -const TREE_PANE_WIDTH = 240 +const TREE_PANE_WIDTH = 300 const INDENT_PX = 12 +const MIN_BAR_PCT = 0.5 interface TraceViewProps { traceSpans: TraceSpan[] @@ -47,6 +48,7 @@ interface FlatSpanEntry { span: TraceSpan depth: number parentIds: string[] + parentDuration?: number } interface BlockAppearance { @@ -186,21 +188,28 @@ function formatTps(outputTokens: number | undefined, durationMs: number): string /** * Flattens the visible (expanded) span tree into a linear list for keyboard - * navigation, carrying depth and the chain of parent ids for indent drawing. + * navigation, carrying depth, the chain of parent ids for indent drawing, and + * the immediate parent's duration for percentage-of-parent calculations. */ function flattenVisible(spans: TraceSpan[], expanded: Set): FlatSpanEntry[] { const out: FlatSpanEntry[] = [] - const walk = (list: TraceSpan[], depth: number, parents: string[]) => { + const walk = ( + list: TraceSpan[], + depth: number, + parents: string[], + parentDuration: number | undefined + ) => { for (const span of list) { const id = getSpanId(span) - out.push({ span, depth, parentIds: parents }) + out.push({ span, depth, parentIds: parents, parentDuration }) const children = getDisplayChildren(span) if (children.length > 0 && expanded.has(id)) { - walk(children, depth + 1, [...parents, id]) + const ownDuration = span.duration || parseTime(span.endTime) - parseTime(span.startTime) + walk(children, depth + 1, [...parents, id], ownDuration) } } } - walk(spans, 0, []) + walk(spans, 0, [], undefined) return out } @@ -270,8 +279,10 @@ function collectMatchingIds(spans: TraceSpan[], query: string): Set { } /** - * Row in the tree pane. Renders the span icon, name, duration, and indentation - * guides. Clicking selects the span; the chevron toggles expansion. + * Row in the tree pane. Renders the span icon, name, duration, a hover tooltip + * with timing context, and a Gantt-style mini timeline bar below the row so the + * span's position within the run is visible at a glance. Clicking selects the + * span; the chevron toggles expansion. */ const TraceTreeRow = memo(function TraceTreeRow({ entry, @@ -281,6 +292,8 @@ const TraceTreeRow = memo(function TraceTreeRow({ onSelect, onToggleExpand, matchQuery, + runStartMs, + runTotalMs, }: { entry: FlatSpanEntry isSelected: boolean @@ -289,22 +302,33 @@ const TraceTreeRow = memo(function TraceTreeRow({ onSelect: (id: string) => void onToggleExpand: (id: string) => void matchQuery: string + runStartMs: number + runTotalMs: number }) { - const { span, depth } = entry + const { span, depth, parentDuration } = entry const id = getSpanId(span) - const duration = span.duration || parseTime(span.endTime) - parseTime(span.startTime) + const startMs = parseTime(span.startTime) + const endMs = parseTime(span.endTime) + const duration = span.duration || endMs - startMs const isRootWorkflow = depth === 0 && span.type?.toLowerCase() === 'workflow' const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span) const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name) const nameMatches = !!matchQuery && spanMatchesQuery(span, matchQuery) + const offsetMs = runStartMs > 0 ? Math.max(0, startMs - runStartMs) : 0 + const offsetPct = runTotalMs > 0 ? Math.min(100, (offsetMs / runTotalMs) * 100) : 0 + const rawDurationPct = runTotalMs > 0 ? (duration / runTotalMs) * 100 : 0 + const durationPct = Math.max(MIN_BAR_PCT, Math.min(100 - offsetPct, rawDurationPct)) + const pctOfTotal = runTotalMs > 0 ? (duration / runTotalMs) * 100 : null + const pctOfParent = + parentDuration && parentDuration > 0 ? (duration / parentDuration) * 100 : null + return (
onSelect(id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -318,44 +342,84 @@ const TraceTreeRow = memo(function TraceTreeRow({ aria-expanded={canExpand ? isExpanded : undefined} aria-level={depth + 1} > - {canExpand ? ( - + ) : ( +
+ )} + {!isIterationType(span.type) && ( +
+ {BlockIcon && } +
+ )} + + + + {span.name} + + + +
+ {span.name} + + {formatDuration(duration, { precision: 2 }) || '—'} + {offsetMs > 0 && ` · +${formatDuration(offsetMs, { precision: 2 })}`} + + {pctOfTotal !== null && pctOfTotal >= 0.1 && ( + + {pctOfTotal.toFixed(pctOfTotal >= 10 ? 0 : 1)}% of total + {pctOfParent !== null && + pctOfParent >= 0.1 && + ` · ${pctOfParent.toFixed(pctOfParent >= 10 ? 0 : 1)}% of parent`} + + )} +
+
+
+ + {formatDuration(duration, { precision: 2 })} + +
+
+
+
- - ) : ( -
- )} - {!isIterationType(span.type) && ( -
- {BlockIcon && }
- )} - - {span.name} - - - {formatDuration(duration, { precision: 2 })} - +
) }) @@ -448,8 +512,10 @@ function DetailCodeSection({ {label}
{isOpen && ( @@ -458,7 +524,7 @@ function DetailCodeSection({ (null) const [searchQuery, setSearchQuery] = useState('') - const [expandedNodes, setExpandedNodes] = useState>(() => new Set()) - const [hasInitialized, setHasInitialized] = useState(false) - const [selectedId, setSelectedId] = useState(null) - - const { normalizedSpans, allIds, totalDuration, firstRootId, blockCount } = useMemo(() => { - const sorted = normalizeAndSort(traceSpans ?? []) - let earliest = Number.POSITIVE_INFINITY - let latest = 0 - for (const span of sorted) { - const s = parseTime(span.startTime) - const e = parseTime(span.endTime) - if (s < earliest) earliest = s - if (e > latest) latest = e - } - const ids = collectAllIds(sorted) - const count = ids.length - return { - normalizedSpans: sorted, - allIds: ids, - totalDuration: latest > earliest ? latest - earliest : 0, - firstRootId: sorted.length > 0 ? getSpanId(sorted[0]) : null, - blockCount: count, - } - }, [traceSpans]) - useEffect(() => { + const { normalizedSpans, allIds, totalDuration, runStartMs, firstRootId, blockCount } = + useMemo(() => { + const sorted = normalizeAndSort(traceSpans ?? []) + let earliest = Number.POSITIVE_INFINITY + let latest = 0 + for (const span of sorted) { + const s = parseTime(span.startTime) + const e = parseTime(span.endTime) + if (s < earliest) earliest = s + if (e > latest) latest = e + } + const ids = collectAllIds(sorted) + const count = ids.length + const runStart = earliest !== Number.POSITIVE_INFINITY ? earliest : 0 + return { + normalizedSpans: sorted, + allIds: ids, + totalDuration: latest > runStart ? latest - runStart : 0, + runStartMs: runStart, + firstRootId: sorted.length > 0 ? getSpanId(sorted[0]) : null, + blockCount: count, + } + }, [traceSpans]) + + const [expandedNodes, setExpandedNodes] = useState>(() => new Set(allIds)) + const [selectedId, setSelectedId] = useState(firstRootId) + const [prevAllIds, setPrevAllIds] = useState(allIds) + if (prevAllIds !== allIds) { + setPrevAllIds(allIds) setExpandedNodes(new Set(allIds)) setSelectedId(firstRootId) - setHasInitialized(true) - }, [allIds, firstRootId]) + } const matchingIds = useMemo( () => (searchQuery ? collectMatchingIds(normalizedSpans, searchQuery) : null), @@ -894,7 +963,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) > {runStatus === 'error' ? 'Error' : 'Success'} - + {formatDuration(totalDuration, { precision: 2 }) || '—'} @@ -949,7 +1018,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) style={{ width: TREE_PANE_WIDTH }} role='tree' > - {hasInitialized && flatList.length === 0 && ( + {flatList.length === 0 && (
No matching spans
)} {flatList.map((entry) => { @@ -965,6 +1034,8 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) onSelect={handleSelect} onToggleExpand={handleToggleExpand} matchQuery={searchQuery} + runStartMs={runStartMs} + runTotalMs={totalDuration} /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 5e854cf3ea8..294a42f932a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -20,7 +20,7 @@ import { SModalTabsTrigger, Tooltip, } from '@/components/emcn' -import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' +import { Copy as CopyIcon, Redo, Search as SearchIcon } from '@/components/emcn/icons' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { cn } from '@/lib/core/utils/cn' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -269,6 +269,10 @@ interface LogDetailsProps { hasNext?: boolean /** Whether there is a previous log available */ hasPrev?: boolean + /** Callback to retry a failed execution */ + onRetryExecution?: () => void + /** Whether a retry is currently in progress */ + isRetryPending?: boolean } /** @@ -287,6 +291,8 @@ export const LogDetails = memo(function LogDetails({ onNavigatePrev, hasNext = false, hasPrev = false, + onRetryExecution, + isRetryPending = false, }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const [activeTab, setActiveTab] = useState('overview') @@ -313,9 +319,7 @@ export const LogDetails = memo(function LogDetails({ const showTraceTab = isWorkflowExecutionLog && !!log?.executionData?.traceSpans && !permissionConfig.hideTraceSpans - useEffect(() => { - if (activeTab === 'trace' && !showTraceTab) setActiveTab('overview') - }, [activeTab, showTraceTab]) + const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab const workflowOutput = useMemo(() => { const executionData = log?.executionData as @@ -325,6 +329,16 @@ export const LogDetails = memo(function LogDetails({ return filterHiddenOutputKeys(executionData.finalOutput) as Record }, [log?.executionData]) + const workflowInput = useMemo(() => { + const executionData = log?.executionData as { workflowInput?: unknown } | undefined + const raw = executionData?.workflowInput + if (raw === undefined || raw === null) return null + if (typeof raw === 'object' && !Array.isArray(raw)) { + return raw as Record + } + return { input: raw } as Record + }, [log?.executionData]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { @@ -397,6 +411,22 @@ export const LogDetails = memo(function LogDetails({ > + {log?.status === 'failed' && (log?.workflow?.id || log?.workflowId) && ( + + + + + Retry + + )} @@ -405,12 +435,12 @@ export const LogDetails = memo(function LogDetails({ {/* Tabs */} setActiveTab(v as LogDetailsTab)} className='mt-4 flex min-h-0 flex-1 flex-col' > Overview @@ -553,6 +583,16 @@ export const LogDetails = memo(function LogDetails({
)} + {/* Workflow Input */} + {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( +
+ + Workflow Input + + +
+ )} + {/* Workflow Output */} {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index a9dba9f471d..01b867e25e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -8,7 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/emcn' -import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons' +import { Copy, Eye, Link, ListFilter, Redo, SquareArrowUpRight, X } from '@/components/emcn/icons' import type { WorkflowLog } from '@/stores/logs/filters/types' interface LogRowContextMenuProps { @@ -23,6 +23,8 @@ interface LogRowContextMenuProps { onToggleWorkflowFilter: () => void onClearAllFilters: () => void onCancelExecution: () => void + onRetryExecution: () => void + isRetryPending?: boolean isFilteredByThisWorkflow: boolean hasActiveFilters: boolean } @@ -43,6 +45,8 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ onToggleWorkflowFilter, onClearAllFilters, onCancelExecution, + onRetryExecution, + isRetryPending = false, isFilteredByThisWorkflow, hasActiveFilters, }: LogRowContextMenuProps) { @@ -50,6 +54,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId) const isCancellable = (log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow + const isRetryable = log?.status === 'failed' && hasWorkflow return ( !open && onClose()} modal={false}> @@ -73,6 +78,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > + {isRetryable && ( + <> + + + {isRetryPending ? 'Retrying...' : 'Retry'} + + + + )} {isCancellable && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index dc2e188432c..aa10477d8dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -13,6 +13,7 @@ import { Download, Library, RefreshCw, + toast, } from '@/components/emcn' import { DatePicker } from '@/components/emcn/components/date-picker/date-picker' import { dollarsToCredits } from '@/lib/billing/credits/conversion' @@ -50,11 +51,14 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { getBlock } from '@/blocks/registry' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { + fetchLogDetail, + logKeys, prefetchLogDetail, useCancelExecution, useDashboardStats, useLogDetail, useLogsList, + useRetryExecution, } from '@/hooks/queries/logs' import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' @@ -71,6 +75,7 @@ import { import { DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_LABEL, + extractRetryInput, formatDate, getDisplayStatus, type LogStatus, @@ -532,6 +537,7 @@ export default function Logs() { }, [contextMenuLog]) const cancelExecution = useCancelExecution() + const retryExecution = useRetryExecution() const handleCancelExecution = useCallback(() => { const workflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId @@ -542,6 +548,37 @@ export default function Logs() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [contextMenuLog]) + const retryLog = useCallback( + async (log: WorkflowLog | null) => { + const workflowId = log?.workflow?.id || log?.workflowId + const logId = log?.id + if (!workflowId || !logId) return + + try { + const detailLog = await queryClient.fetchQuery({ + queryKey: logKeys.detail(logId), + queryFn: ({ signal }) => fetchLogDetail(logId, signal), + staleTime: 30 * 1000, + }) + const input = extractRetryInput(detailLog) + await retryExecution.mutateAsync({ workflowId, input }) + toast.success('Retry started') + } catch { + toast.error('Failed to retry execution') + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + const handleRetryExecution = useCallback(() => { + retryLog(contextMenuLog) + }, [contextMenuLog, retryLog]) + + const handleRetrySidebarExecution = useCallback(() => { + retryLog(selectedLog) + }, [selectedLog, retryLog]) + const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId const isFilteredByThisWorkflow = Boolean( contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId @@ -780,6 +817,8 @@ export default function Logs() { onNavigatePrev={handleNavigatePrev} hasNext={selectedLogIndex < sortedLogs.length - 1} hasPrev={selectedLogIndex > 0} + onRetryExecution={handleRetrySidebarExecution} + isRetryPending={retryExecution.isPending} /> ), [ @@ -788,6 +827,8 @@ export default function Logs() { handleCloseSidebar, handleNavigateNext, handleNavigatePrev, + handleRetrySidebarExecution, + retryExecution.isPending, selectedLogIndex, sortedLogs.length, ] @@ -1193,6 +1234,8 @@ export default function Logs() { onOpenWorkflow={handleOpenWorkflow} onOpenPreview={handleOpenPreview} onCancelExecution={handleCancelExecution} + onRetryExecution={handleRetryExecution} + isRetryPending={retryExecution.isPending} onToggleWorkflowFilter={handleToggleWorkflowFilter} onClearAllFilters={handleClearAllFilters} isFilteredByThisWorkflow={isFilteredByThisWorkflow} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 535ab8000d9..bfca8a90b5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -4,6 +4,7 @@ import { format } from 'date-fns' import { Badge } from '@/components/emcn' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' +import type { WorkflowLog } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' export const LOG_COLUMNS = { @@ -442,3 +443,37 @@ export const formatDate = (dateString: string) => { })(), } } + +/** + * Extracts the original workflow input from a log entry for retry. + * Prefers the persisted `workflowInput` field (new logs), falls back to + * reconstructing from `executionState.blockStates` (old logs). + */ +export function extractRetryInput(log: WorkflowLog): unknown | undefined { + const execData = log.executionData as Record | undefined + if (!execData) return undefined + + if (execData.workflowInput !== undefined) { + return execData.workflowInput + } + + const executionState = execData.executionState as + | { + blockStates?: Record< + string, + { output?: unknown; executed?: boolean; executionTime?: number } + > + } + | undefined + if (!executionState?.blockStates) return undefined + + // Starter/trigger blocks are pre-populated with executed: false and + // executionTime: 0, which distinguishes them from blocks that actually ran. + for (const state of Object.values(executionState.blockStates)) { + if (state.executed === false && state.executionTime === 0 && state.output != null) { + return state.output + } + } + + return undefined +} diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index bbbcea5ba7c..6ecb0f74f32 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -121,7 +121,7 @@ async function fetchLogsPage( } } -async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { +export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { const response = await fetch(`/api/logs/${logId}`, { signal }) if (!response.ok) { @@ -332,3 +332,34 @@ export function useCancelExecution() { }, }) } + +export function useRetryExecution() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ workflowId, input }: { workflowId: string; input?: unknown }) => { + const res = await fetch(`/api/workflows/${workflowId}/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input, triggerType: 'manual', stream: true }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to retry execution') + } + // The ReadableStream is lazy — start() only runs when read. + // Read one chunk to trigger execution, then cancel. Execution continues + // server-side after client disconnect. + const reader = res.body?.getReader() + if (reader) { + await reader.read() + reader.cancel() + } + return { started: true } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: logKeys.lists() }) + queryClient.invalidateQueries({ queryKey: logKeys.details() }) + queryClient.invalidateQueries({ queryKey: logKeys.statsAll() }) + }, + }) +} diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 707673b3431..07b7af219bb 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -73,6 +73,7 @@ export class ExecutionLogger implements IExecutionLoggerService { models: NonNullable } executionState?: SerializableExecutionState + workflowInput?: unknown }): WorkflowExecutionLog['executionData'] { const { existingExecutionData, @@ -82,6 +83,7 @@ export class ExecutionLogger implements IExecutionLoggerService { completionFailure, executionCost, executionState, + workflowInput, } = params const traceSpanCount = countTraceSpans(traceSpans) @@ -117,6 +119,7 @@ export class ExecutionLogger implements IExecutionLoggerService { }, models: executionCost.models, ...(executionState ? { executionState } : {}), + ...(workflowInput !== undefined ? { workflowInput } : {}), } } @@ -365,6 +368,7 @@ export class ExecutionLogger implements IExecutionLoggerService { completionFailure, executionCost, executionState, + workflowInput, }) const [updatedLog] = await db diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index 018adf429c0..e64fc91d56c 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -155,6 +155,7 @@ export interface WorkflowExecutionLog { > executionState?: SerializableExecutionState finalOutput?: any + workflowInput?: unknown errorDetails?: { blockId: string blockName: string From e2875db06b80f01412cdf71c4e204a246e255218 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Apr 2026 15:02:17 -0700 Subject: [PATCH 04/28] fix(ui): use inverted popover scheme for usage-control popovers Co-Authored-By: Claude Opus 4.7 --- .../sub-block/components/messages-input/messages-input.tsx | 1 + .../components/sub-block/components/tool-input/tool-input.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx index 361a85582d3..55a03aacf56 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx @@ -573,6 +573,7 @@ export function MessagesInput({ setOpenPopoverIndex(open ? index : null)} + colorScheme='inverted' >
+ )} - {/* Version */} - {log.deploymentVersion && ( -
- - Version - -
- - {log.deploymentVersionName || `v${log.deploymentVersion}`} - -
-
- )} + {/* Workflow Input */} + {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( +
+ + Workflow Input + +
+ )} - {/* Workflow State */} - {isWorkflowExecutionLog && - log.executionId && - log.trigger !== 'mothership' && - !permissionConfig.hideTraceSpans && ( -
- - Workflow State - - -
- )} + {/* Workflow Output */} + {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( +
+ + Workflow Output + + +
+ )} - {/* Workflow Input */} - {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( + {/* Workflow Execution - Trace Spans */} + {isWorkflowExecutionLog && + log.executionData?.traceSpans && + !permissionConfig.hideTraceSpans && (
- Workflow Input + Trace Span - +
)} - {/* Workflow Output */} - {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( -
- - Workflow Output - - -
- )} + {/* Files */} + {log.files && log.files.length > 0 && ( + + )} - {/* Workflow Execution - Trace Spans */} - {isWorkflowExecutionLog && - log.executionData?.traceSpans && - !permissionConfig.hideTraceSpans && ( -
- - Trace Span - - + {/* Cost Breakdown */} + {hasCostInfo && ( +
+ + Cost Breakdown + + +
+
+
+ + Base Run: + + + {formatCost(BASE_EXECUTION_CHARGE)} + +
+
+ + Model Input: + + + {formatCost(log.cost?.input || 0)} + +
+
+ + Model Output: + + + {formatCost(log.cost?.output || 0)} + +
+ {(() => { + const models = (log.cost as Record)?.models as + | Record + | undefined + const totalToolCost = models + ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) + : 0 + return totalToolCost > 0 ? ( +
+ + Tool Usage: + + + {formatCost(totalToolCost)} + +
+ ) : null + })()}
- )} - {/* Files */} - {log.files && log.files.length > 0 && ( - - )} - - {/* Cost Breakdown */} - {hasCostInfo && ( -
- - Cost Breakdown - +
-
-
-
- - Base Run: - - - {formatCost(BASE_EXECUTION_CHARGE)} - -
-
- - Model Input: - - - {formatCost(log.cost?.input || 0)} - -
-
- - Model Output: - - - {formatCost(log.cost?.output || 0)} - -
- {(() => { - const models = (log.cost as Record)?.models as - | Record - | undefined - const totalToolCost = models - ? Object.values(models).reduce( - (sum, m) => sum + (m?.toolCost || 0), - 0 - ) - : 0 - return totalToolCost > 0 ? ( -
- - Tool Usage: - - - {formatCost(totalToolCost)} - -
- ) : null - })()} +
+
+ + Total: + + + {formatCost(log.cost?.total || 0)} +
- -
- -
-
- - Total: - - - {formatCost(log.cost?.total || 0)} - -
-
- - Tokens: - - - {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} - {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out - -
+
+ + Tokens: + + + {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} + {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out +
+
-
-

- Total cost includes a base run charge of{' '} - {formatCost(BASE_EXECUTION_CHARGE)} plus any model and tool usage costs. -

-
+
+

+ Total cost includes a base run charge of {formatCost(BASE_EXECUTION_CHARGE)}{' '} + plus any model and tool usage costs. +

- )} -
+
+ )} {/* Trace Tab */} From 9808e757e4274424d415a075dc87e1f6d10512e8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Apr 2026 18:04:00 -0700 Subject: [PATCH 08/28] fix(logs): hide inactive Overview tab panel Tailwind's `.flex` utility overrides the UA `[hidden]` rule, so applying `flex` to SModalTabsContent caused the inactive Overview panel to still participate in the Tabs flex column and push the Trace view down. Keep SModalTabsContent as a plain overflow container (no `flex` class) with the scroll ref on it, and restore the inner flex-col wrapper for the Overview content so it still stacks with gap spacing. --- .../components/log-details/log-details.tsx | 459 +++++++++--------- 1 file changed, 232 insertions(+), 227 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 24d0ed96ddb..934c29a82da 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -455,269 +455,274 @@ export const LogDetails = memo(function LogDetails({ - {/* Timestamp & Workflow Row */} -
- {/* Timestamp Card */} -
-
- Timestamp +
+ {/* Timestamp & Workflow Row */} +
+ {/* Timestamp Card */} +
+
+ Timestamp +
+
+ + {formattedTimestamp?.compactDate || 'N/A'} + + + {formattedTimestamp?.compactTime || 'N/A'} + +
-
- - {formattedTimestamp?.compactDate || 'N/A'} - - - {formattedTimestamp?.compactTime || 'N/A'} - + + {/* Workflow Card */} +
+
+ {log.trigger === 'mothership' ? 'Job' : 'Workflow'} +
+
+ {(() => { + const c = + log.trigger === 'mothership' + ? '#ec4899' + : log.workflow?.color || + (!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined) + return ( +
+ ) + })()} + + {log.trigger === 'mothership' + ? log.jobTitle || 'Untitled Job' + : log.workflow?.name || + (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} + +
- {/* Workflow Card */} -
-
- {log.trigger === 'mothership' ? 'Job' : 'Workflow'} -
-
- {(() => { - const c = - log.trigger === 'mothership' - ? '#ec4899' - : log.workflow?.color || - (!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined) - return ( -
- ) - })()} - - {log.trigger === 'mothership' - ? log.jobTitle || 'Untitled Job' - : log.workflow?.name || - (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} + {/* Run ID */} + {log.executionId && ( +
+ + Run ID + + + {log.executionId}
-
-
- - {/* Run ID */} - {log.executionId && ( -
- - Run ID - - - {log.executionId} - -
- )} - - {/* Details Section */} -
- {/* Level */} -
- - Level - - -
+ )} - {/* Trigger */} -
- - Trigger - - {log.trigger ? ( - - ) : ( - - — + {/* Details Section */} +
+ {/* Level */} +
+ + Level - )} -
- - {/* Duration */} -
- - Duration - - - {formatDuration(log.duration, { precision: 2 }) || '—'} - -
+ +
- {/* Version */} - {log.deploymentVersion && ( -
- - Version + {/* Trigger */} +
+ + Trigger -
- - {log.deploymentVersionName || `v${log.deploymentVersion}`} + {log.trigger ? ( + + ) : ( + + — -
+ )}
- )} -
- {/* Workflow State */} - {isWorkflowExecutionLog && - log.executionId && - log.trigger !== 'mothership' && - !permissionConfig.hideTraceSpans && ( -
+ {/* Duration */} +
- Workflow State + Duration + + + {formatDuration(log.duration, { precision: 2 }) || '—'} -
- )} - {/* Workflow Input */} - {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( -
- - Workflow Input - - + {/* Version */} + {log.deploymentVersion && ( +
+ + Version + +
+ + {log.deploymentVersionName || `v${log.deploymentVersion}`} + +
+
+ )}
- )} - {/* Workflow Output */} - {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( -
- - Workflow Output - - -
- )} + {/* Workflow State */} + {isWorkflowExecutionLog && + log.executionId && + log.trigger !== 'mothership' && + !permissionConfig.hideTraceSpans && ( +
+ + Workflow State + + +
+ )} - {/* Workflow Execution - Trace Spans */} - {isWorkflowExecutionLog && - log.executionData?.traceSpans && - !permissionConfig.hideTraceSpans && ( + {/* Workflow Input */} + {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && (
- Trace Span + Workflow Input - +
)} - {/* Files */} - {log.files && log.files.length > 0 && ( - - )} + {/* Workflow Output */} + {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( +
+ + Workflow Output + + +
+ )} - {/* Cost Breakdown */} - {hasCostInfo && ( -
- - Cost Breakdown - - -
-
-
- - Base Run: - - - {formatCost(BASE_EXECUTION_CHARGE)} - -
-
- - Model Input: - - - {formatCost(log.cost?.input || 0)} - -
-
- - Model Output: - - - {formatCost(log.cost?.output || 0)} - -
- {(() => { - const models = (log.cost as Record)?.models as - | Record - | undefined - const totalToolCost = models - ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) - : 0 - return totalToolCost > 0 ? ( -
- - Tool Usage: - - - {formatCost(totalToolCost)} - -
- ) : null - })()} + {/* Workflow Execution - Trace Spans */} + {isWorkflowExecutionLog && + log.executionData?.traceSpans && + !permissionConfig.hideTraceSpans && ( +
+ + Trace Span + +
+ )} -
+ {/* Files */} + {log.files && log.files.length > 0 && ( + + )} -
-
- - Total: - - - {formatCost(log.cost?.total || 0)} - + {/* Cost Breakdown */} + {hasCostInfo && ( +
+ + Cost Breakdown + + +
+
+
+ + Base Run: + + + {formatCost(BASE_EXECUTION_CHARGE)} + +
+
+ + Model Input: + + + {formatCost(log.cost?.input || 0)} + +
+
+ + Model Output: + + + {formatCost(log.cost?.output || 0)} + +
+ {(() => { + const models = (log.cost as Record)?.models as + | Record + | undefined + const totalToolCost = models + ? Object.values(models).reduce( + (sum, m) => sum + (m?.toolCost || 0), + 0 + ) + : 0 + return totalToolCost > 0 ? ( +
+ + Tool Usage: + + + {formatCost(totalToolCost)} + +
+ ) : null + })()}
-
- - Tokens: - - - {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} - {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out - + +
+ +
+
+ + Total: + + + {formatCost(log.cost?.total || 0)} + +
+
+ + Tokens: + + + {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} + {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out + +
-
-
-

- Total cost includes a base run charge of {formatCost(BASE_EXECUTION_CHARGE)}{' '} - plus any model and tool usage costs. -

+
+

+ Total cost includes a base run charge of{' '} + {formatCost(BASE_EXECUTION_CHARGE)} plus any model and tool usage costs. +

+
-
- )} + )} +
{/* Trace Tab */} From 50e8e1e1ec4bbe6a373c796588dacf35bb2412bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Apr 2026 18:19:47 -0700 Subject: [PATCH 09/28] fix(logs): trace view padding, section cutoff, keyboard visibility - Tree pane now has top padding so the first row has breathing room under the header strip instead of sitting flush against the border. - DetailCodeSection dropped its wrapper `overflow-hidden`. Per CSS, a flex item with `overflow: hidden` resolves `min-height: auto` to `0`, so when Input and Output were both expanded the flex algorithm shrank each section below its content, cutting off rows. Without the clip, sections size to content and the surrounding pane's `overflow-y-auto` takes over. - Selected span row now scrolls into view on selection change, so arrow-key navigation always keeps the active row visible in the tree pane. --- .../components/trace-view/trace-view.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 46b46a8efb9..9e00d1e521e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -342,6 +342,7 @@ const TraceTreeRow = memo(function TraceTreeRow({ aria-selected={isSelected} aria-expanded={canExpand ? isExpanded : undefined} aria-level={depth + 1} + data-span-id={id} >
+
setIsOpen((v) => !v)} @@ -830,6 +831,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa * follow block-by-block and segment-by-segment what happened and why. */ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) { + const treeRef = useRef(null) const [searchQuery, setSearchQuery] = useState('') const { normalizedSpans, allIds, totalDuration, runStartMs, firstRootId, blockCount } = @@ -950,6 +952,14 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) return () => window.removeEventListener('keydown', handler) }, [flatList, selectedId, expandedNodes, handleToggleExpand]) + useEffect(() => { + if (!selectedId || !treeRef.current) return + const row = treeRef.current.querySelector( + `[data-span-id="${CSS.escape(selectedId)}"]` + ) + row?.scrollIntoView({ block: 'nearest' }) + }, [selectedId]) + if (!traceSpans || traceSpans.length === 0) { return (
@@ -1023,7 +1033,8 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) {/* Tree + detail split */}
From e3de715f9790f848c00c191b7451db478fbe3da4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Apr 2026 18:44:56 -0700 Subject: [PATCH 10/28] fix(logs): inline Workflow State row and lift search dropdown z-index Co-Authored-By: Claude Opus 4.7 --- .../components/log-details/log-details.tsx | 50 ++++++++++++------- .../logs-toolbar/components/search/search.tsx | 2 +- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 934c29a82da..0c8dd6dae9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -316,6 +316,11 @@ export const LogDetails = memo(function LogDetails({ !!(log.executionData?.enhanced && log.executionData?.traceSpans)) const hasCostInfo = !!(isWorkflowExecutionLog && log?.cost) + const showWorkflowState = + isWorkflowExecutionLog && + !!log?.executionId && + log?.trigger !== 'mothership' && + !permissionConfig.hideTraceSpans const showTraceTab = isWorkflowExecutionLog && !!log?.executionData?.traceSpans && !permissionConfig.hideTraceSpans @@ -546,19 +551,29 @@ export const LogDetails = memo(function LogDetails({ {/* Duration */}
Duration - + {formatDuration(log.duration, { precision: 2 }) || '—'}
{/* Version */} {log.deploymentVersion && ( -
+
Version @@ -569,27 +584,24 @@ export const LogDetails = memo(function LogDetails({
)} -
- {/* Workflow State */} - {isWorkflowExecutionLog && - log.executionId && - log.trigger !== 'mothership' && - !permissionConfig.hideTraceSpans && ( -
+ {/* Workflow State */} + {showWorkflowState && ( + -
+ + View Snapshot + + + )} +
{/* Workflow Input */} {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index b895f447b57..cbaed69a90b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx @@ -248,7 +248,7 @@ export function AutocompleteSearch({ Date: Sat, 18 Apr 2026 18:48:54 -0700 Subject: [PATCH 11/28] fix(logs): use emcn Button for View Snapshot action Co-Authored-By: Claude Opus 4.7 --- .../components/log-details/log-details.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 0c8dd6dae9d..c68fc8d7934 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -587,19 +587,19 @@ export const LogDetails = memo(function LogDetails({ {/* Workflow State */} {showWorkflowState && ( - + + +
)}
From ee35ee47037447dc9d043c9f401f5c8a950cd1d1 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 18 Apr 2026 19:40:51 -0700 Subject: [PATCH 12/28] minor improvements --- .../components/log-details/log-details.tsx | 378 +++++++++++------- .../emcn/components/modal/modal.tsx | 10 +- 2 files changed, 232 insertions(+), 156 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index c68fc8d7934..c4c8632e249 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -12,7 +12,6 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, - Eye, Input, SModalTabs, SModalTabsContent, @@ -24,11 +23,11 @@ import { Copy as CopyIcon, Redo, Search as SearchIcon } from '@/components/emcn/ import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { cn } from '@/lib/core/utils/cn' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' +import type { TraceSpan } from '@/lib/logs/types' import { workflowBorderColor } from '@/lib/workspaces/colors' import { ExecutionSnapshot, FileCards, - TraceSpans, TraceView, } from '@/app/workspace/[workspaceId]/logs/components' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' @@ -40,6 +39,7 @@ import { StatusBadge, TriggerBadge, } from '@/app/workspace/[workspaceId]/logs/utils' +import { getBlock } from '@/blocks/registry' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { usePermissionConfig } from '@/hooks/use-permission-config' import { formatCost } from '@/providers/utils' @@ -254,6 +254,97 @@ export const WorkflowOutputSection = memo( (prev, next) => prev.output === next.output ) +/** + * Compact horizontal timeline showing each block's execution as a proportional colored segment. + */ +function ExecutionTimeline({ traceSpans }: { traceSpans: TraceSpan[] }) { + const { segments, totalDuration } = useMemo(() => { + if (!traceSpans || traceSpans.length === 0) return { segments: [], totalDuration: 0 } + + const rootSpan = traceSpans[0] + if (!rootSpan) return { segments: [], totalDuration: 0 } + + const rootStart = new Date(rootSpan.startTime).getTime() + const rootEnd = new Date(rootSpan.endTime).getTime() + const total = rootSpan.duration || rootEnd - rootStart + if (total <= 0) return { segments: [], totalDuration: 0 } + + const children = (rootSpan.children || []).filter( + (c) => c.type.toLowerCase() !== 'workflow' && c.name !== 'Start' + ) + const segs = children.map((child) => { + const childStart = new Date(child.startTime).getTime() + const childEnd = new Date(child.endTime).getTime() + const childDuration = child.duration || childEnd - childStart + const startPct = ((childStart - rootStart) / total) * 100 + const widthPct = (childDuration / total) * 100 + + const lowerType = child.type.toLowerCase() + const blockType = lowerType === 'model' ? 'agent' : lowerType + const blockConfig = getBlock(blockType) + const color = + lowerType === 'workflow' + ? '#6366F1' + : lowerType === 'loop' || lowerType === 'loop-iteration' + ? '#F59E0B' + : lowerType === 'parallel' || lowerType === 'parallel-iteration' + ? '#10B981' + : (blockConfig?.bgColor ?? '#6366F1') + + return { + name: child.name, + color, + startPct: Math.max(0, Math.min(100, startPct)), + widthPct: Math.max(0.5, Math.min(100, widthPct)), + duration: childDuration, + status: child.status, + } + }) + + return { segments: segs, totalDuration: total } + }, [traceSpans]) + + if (segments.length === 0) return null + + return ( +
+
+ Execution + + {formatDuration(totalDuration, { precision: 2 })} + +
+
+ {segments.map((seg, i) => ( +
+ ))} +
+
+ {segments.map((seg, i) => ( +
+
+ {seg.name} + + {formatDuration(seg.duration, { precision: 1 })} + +
+ ))} +
+
+ ) +} + interface LogDetailsProps { /** The log to display details for */ log: WorkflowLog | null @@ -296,7 +387,15 @@ export const LogDetails = memo(function LogDetails({ }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const [activeTab, setActiveTab] = useState('overview') + const [copiedRunId, setCopiedRunId] = useState(false) + const copiedRunIdTimerRef = useRef(null) const scrollAreaRef = useRef(null) + + useEffect(() => { + return () => { + if (copiedRunIdTimerRef.current !== null) window.clearTimeout(copiedRunIdTimerRef.current) + } + }, []) const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) const { handleMouseDown } = useLogDetailsResize() const { config: permissionConfig } = usePermissionConfig() @@ -463,29 +562,33 @@ export const LogDetails = memo(function LogDetails({ className='mt-4 min-h-0 flex-1 overflow-y-auto' >
- {/* Timestamp & Workflow Row */} -
- {/* Timestamp Card */} -
-
+ {/* Execution Timeline */} + {isWorkflowExecutionLog && + log.executionData?.traceSpans && + !permissionConfig.hideTraceSpans && ( + + )} + + {/* Details Section */} +
+ {/* Timestamp */} +
+ Timestamp -
-
- - {formattedTimestamp?.compactDate || 'N/A'} - - - {formattedTimestamp?.compactTime || 'N/A'} - -
+ + + {formattedTimestamp + ? `${formattedTimestamp.compactDate} ${formattedTimestamp.compactTime}` + : 'N/A'} +
- {/* Workflow Card */} -
-
+ {/* Workflow / Job */} +
+ {log.trigger === 'mothership' ? 'Job' : 'Workflow'} -
-
+ +
{(() => { const c = log.trigger === 'mothership' @@ -494,7 +597,7 @@ export const LogDetails = memo(function LogDetails({ (!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined) return (
) })()} - + {log.trigger === 'mothership' ? log.jobTitle || 'Untitled Job' : log.workflow?.name || @@ -511,24 +614,39 @@ export const LogDetails = memo(function LogDetails({
-
- {/* Run ID */} - {log.executionId && ( -
- - Run ID - - - {log.executionId} - -
- )} + {/* Run ID — click to copy */} + {log.executionId && ( +
{ + navigator.clipboard.writeText(log.executionId!) + if (copiedRunIdTimerRef.current) clearTimeout(copiedRunIdTimerRef.current) + setCopiedRunId(true) + copiedRunIdTimerRef.current = window.setTimeout( + () => setCopiedRunId(false), + 1500 + ) + }} + > + + Run ID + + + {copiedRunId ? 'Copied!' : log.executionId} + +
+ )} - {/* Details Section */} -
{/* Level */} -
+
Level @@ -536,7 +654,7 @@ export const LogDetails = memo(function LogDetails({
{/* Trigger */} -
+
Trigger @@ -550,14 +668,7 @@ export const LogDetails = memo(function LogDetails({
{/* Duration */} -
+
Duration @@ -568,12 +679,7 @@ export const LogDetails = memo(function LogDetails({ {/* Version */} {log.deploymentVersion && ( -
+
Version @@ -587,25 +693,23 @@ export const LogDetails = memo(function LogDetails({ {/* Workflow State */} {showWorkflowState && ( -
+
setIsExecutionSnapshotOpen(true)} + > Workflow State - +
)}
{/* Workflow Input */} {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( -
+
Workflow Input @@ -615,7 +719,7 @@ export const LogDetails = memo(function LogDetails({ {/* Workflow Output */} {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( -
+
)} - {/* Workflow Execution - Trace Spans */} - {isWorkflowExecutionLog && - log.executionData?.traceSpans && - !permissionConfig.hideTraceSpans && ( -
- - Trace Span - - -
- )} - {/* Files */} {log.files && log.files.length > 0 && ( @@ -649,87 +741,71 @@ export const LogDetails = memo(function LogDetails({ {/* Cost Breakdown */} {hasCostInfo && ( -
- - Cost Breakdown - - -
-
-
- - Base Run: - - - {formatCost(BASE_EXECUTION_CHARGE)} - -
-
- - Model Input: - - - {formatCost(log.cost?.input || 0)} - -
-
- - Model Output: - - - {formatCost(log.cost?.output || 0)} - -
- {(() => { - const models = (log.cost as Record)?.models as - | Record - | undefined - const totalToolCost = models - ? Object.values(models).reduce( - (sum, m) => sum + (m?.toolCost || 0), - 0 - ) - : 0 - return totalToolCost > 0 ? ( -
- - Tool Usage: - - - {formatCost(totalToolCost)} - -
- ) : null - })()} -
- -
- -
-
- - Total: - - - {formatCost(log.cost?.total || 0)} - -
-
+
+
+ + Base Run + + + {formatCost(BASE_EXECUTION_CHARGE)} + +
+
+ + Model Input + + + {formatCost(log.cost?.input || 0)} + +
+
+ + Model Output + + + {formatCost(log.cost?.output || 0)} + +
+ {(() => { + const models = (log.cost as Record)?.models as + | Record + | undefined + const totalToolCost = models + ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) + : 0 + return totalToolCost > 0 ? ( +
- Tokens: + Tool Usage - - {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} - {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out + + {formatCost(totalToolCost)}
-
+ ) : null + })()} +
+
+ + Total + + + {formatCost(log.cost?.total || 0)} +
- -
-

- Total cost includes a base run charge of{' '} - {formatCost(BASE_EXECUTION_CHARGE)} plus any model and tool usage costs. +

+ + Tokens + + + {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in ·{' '} + {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out + +
+
+

+ Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus + model and tool usage.

diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index dcca3f3cb69..a04b5009eb6 100644 --- a/apps/sim/components/emcn/components/modal/modal.tsx +++ b/apps/sim/components/emcn/components/modal/modal.tsx @@ -157,11 +157,11 @@ const ModalContent = React.forwardRef<
Date: Fri, 24 Apr 2026 15:53:34 -0700 Subject: [PATCH 13/28] fix(logs): trace view resizable split, bar visibility, provider icons, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resizable tree/detail split in trace view (default 360px, drag to resize) - Resizable right panel in preview snapshot (280–600px) - Fix Gantt bar invisibility for late-run spans (clamp offsetPct to 100-MIN_BAR_PCT) - Propagate model+provider to child model spans in span-factory for correct icons - Fix icon contrast on light provider backgrounds (luminance-based color class) - Replace custom status badges with emcn Badge component - Lighten jump-to-error button to ghost variant - Remove double X button in modal snapshot (showBlockCloseButton prop) - Fix emcn subpath imports → barrel in trace-view, log-details, execution-snapshot - Fix hover: → hover-hover: on resize handles - Add body style cleanup on resize unmount - Fix React Query key factory naming (stats/stat convention) - Remove unnecessary useCallback/useMemo in preview and execution-snapshot --- .../execution-snapshot/execution-snapshot.tsx | 18 +- .../components/trace-view/trace-view.tsx | 265 ++++++++++++------ .../components/log-details/log-details.tsx | 252 +++++------------ .../preview-editor/preview-editor.tsx | 21 +- .../w/components/preview/preview.tsx | 174 +++++++----- apps/sim/hooks/queries/logs.ts | 12 +- .../execution/trace-spans/span-factory.ts | 3 + 7 files changed, 396 insertions(+), 349 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx index 2f2ebd93182..3a2c2af0d42 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -1,9 +1,11 @@ 'use client' -import { useCallback, useRef, useState } from 'react' +import type React from 'react' +import { useRef, useState } from 'react' import { AlertCircle, Loader2 } from 'lucide-react' import { createPortal } from 'react-dom' import { + Copy, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -13,7 +15,6 @@ import { ModalContent, ModalHeader, } from '@/components/emcn' -import { Copy } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview' import { useExecutionSnapshot } from '@/hooks/queries/logs' @@ -64,21 +65,21 @@ export function ExecutionSnapshot({ const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) const menuRef = useRef(null) - const closeMenu = useCallback(() => { + function closeMenu() { setIsMenuOpen(false) - }, []) + } - const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => { + function handleCanvasContextMenu(e: React.MouseEvent) { e.preventDefault() e.stopPropagation() setMenuPosition({ x: e.clientX, y: e.clientY }) setIsMenuOpen(true) - }, []) + } - const handleCopyExecutionId = useCallback(() => { + function handleCopyExecutionId() { navigator.clipboard.writeText(executionId) closeMenu() - }, [executionId, closeMenu]) + } const workflowState = data?.workflowState as WorkflowState | undefined const childWorkflowSnapshots = data?.childWorkflowSnapshots as @@ -161,6 +162,7 @@ export function ExecutionSnapshot({ onCanvasContextMenu={handleCanvasContextMenu} showBorder={!isModal} autoSelectLeftmost + showBlockCloseButton={!isModal} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 9e00d1e521e..1505fd5c332 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { formatDuration } from '@sim/utils/formatting' import { ArrowDown, ArrowUp, @@ -14,29 +15,33 @@ import { } from 'lucide-react' import { createPortal } from 'react-dom' import { + Badge, Button, ChevronDown, Code, + Copy as CopyIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, + Search as SearchIcon, Tooltip, } from '@/components/emcn' -import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' -import { formatDuration } from '@/lib/core/utils/formatting' import type { TraceSpan } from '@/lib/logs/types' 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' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' +import { PROVIDER_DEFINITIONS } from '@/providers/models' const DEFAULT_BLOCK_COLOR = '#6b7280' -const TREE_PANE_WIDTH = 300 +const DEFAULT_TREE_PANE_WIDTH = 360 +const MIN_TREE_PANE_WIDTH = 200 +const MAX_TREE_PANE_WIDTH = 600 const INDENT_PX = 12 const ROW_BASE_PADDING_LEFT = 14 const MIN_BAR_PCT = 0.5 @@ -145,7 +150,7 @@ function getDisplayChildren(span: TraceSpan): TraceSpan[] { /** * Resolves the block icon and accent color for a trace span type. */ -function getBlockAppearance(type: string, toolName?: string): BlockAppearance { +function getBlockAppearance(type: string, toolName?: string, provider?: string): BlockAppearance { const lowerType = type.toLowerCase() if (lowerType === 'tool' && toolName) { if (toolName === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } @@ -157,12 +162,28 @@ function getBlockAppearance(type: string, toolName?: string): BlockAppearance { if (lowerType === 'parallel' || lowerType === 'parallel-iteration') return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor } if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' } + if (lowerType === 'model' && provider) { + const providerDef = PROVIDER_DEFINITIONS[provider] + if (providerDef?.icon) { + return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR } + } + } const blockType = lowerType === 'model' ? 'agent' : lowerType const blockConfig = getBlock(blockType) if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor } return { icon: null, bgColor: DEFAULT_BLOCK_COLOR } } +/** Returns 'text-white' for dark backgrounds, dark text for light ones. */ +function iconColorClass(bgColor: string): string { + const hex = bgColor.replace('#', '') + if (hex.length !== 6) return 'text-white' + const r = Number.parseInt(hex.slice(0, 2), 16) + const g = Number.parseInt(hex.slice(2, 4), 16) + const b = Number.parseInt(hex.slice(4, 6), 16) + return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white' +} + function formatTokenCount(value: number | undefined): string | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined return value.toLocaleString('en-US') @@ -230,6 +251,28 @@ function collectAllIds(spans: TraceSpan[]): string[] { return out } +/** + * Finds the leaf-most errored span — the actual error source rather than a + * parent span that has its status propagated up from a child. When an errored + * span has errored children, we recurse into those children first; we only + * return the current span if none of its descendants are also errored. + */ +function findLeafErrorSpan(spans: TraceSpan[]): TraceSpan | null { + for (const span of spans) { + if (span.status === 'error') { + const children = getDisplayChildren(span) + const childError = findLeafErrorSpan(children) + return childError ?? span + } + const children = getDisplayChildren(span) + if (children.length > 0) { + const found = findLeafErrorSpan(children) + if (found) return found + } + } + return null +} + /** * Finds a span by id anywhere in the tree. */ @@ -313,11 +356,11 @@ const TraceTreeRow = memo(function TraceTreeRow({ const duration = span.duration || endMs - startMs const isRootWorkflow = depth === 0 && span.type?.toLowerCase() === 'workflow' const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span) - const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name) + const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name, span.provider) const nameMatches = !!matchQuery && spanMatchesQuery(span, matchQuery) const offsetMs = runStartMs > 0 ? Math.max(0, startMs - runStartMs) : 0 - const offsetPct = runTotalMs > 0 ? Math.min(100, (offsetMs / runTotalMs) * 100) : 0 + const offsetPct = runTotalMs > 0 ? Math.min(100 - MIN_BAR_PCT, (offsetMs / runTotalMs) * 100) : 0 const rawDurationPct = runTotalMs > 0 ? (duration / runTotalMs) * 100 : 0 const durationPct = Math.max(MIN_BAR_PCT, Math.min(100 - offsetPct, rawDurationPct)) const pctOfTotal = runTotalMs > 0 ? (duration / runTotalMs) * 100 : null @@ -349,9 +392,10 @@ const TraceTreeRow = memo(function TraceTreeRow({ style={{ paddingLeft: ROW_BASE_PADDING_LEFT + depth * INDENT_PX }} > {canExpand ? ( - + ) : (
)} @@ -373,7 +417,9 @@ const TraceTreeRow = memo(function TraceTreeRow({ className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm' style={{ background: bgColor }} > - {BlockIcon && } + {BlockIcon && ( + + )}
)} @@ -410,7 +456,7 @@ const TraceTreeRow = memo(function TraceTreeRow({ {formatDuration(duration, { precision: 2 })}
-
+
{ + function handleContextMenu(e: React.MouseEvent) { e.preventDefault() e.stopPropagation() setContextMenuPosition({ x: e.clientX, y: e.clientY }) setIsContextMenuOpen(true) - }, []) - - const closeContextMenu = useCallback(() => setIsContextMenuOpen(false), []) + } - const handleCopy = useCallback(() => { + function handleCopy() { navigator.clipboard.writeText(jsonString) setCopied(true) setTimeout(() => setCopied(false), 1500) - closeContextMenu() - }, [jsonString, closeContextMenu]) + setIsContextMenuOpen(false) + } - const handleSearch = useCallback(() => { + function handleSearch() { activateSearch() - closeContextMenu() - }, [activateSearch, closeContextMenu]) + setIsContextMenuOpen(false) + } return (
@@ -624,7 +668,11 @@ function DetailCodeSection({ )} {typeof document !== 'undefined' && createPortal( - + setIsContextMenuOpen(false)} + modal={false} + >
- {BlockIcon && } + {BlockIcon && ( + + )}
)}
@@ -754,16 +800,9 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa {span.name}
- + {statusLabel} - + · {formatDuration(duration, { precision: 2 }) || '—'} {Number.isFinite(startedAt) && startedAt > 0 && ( @@ -816,8 +855,12 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa {Number.isFinite(startedAt) && Number.isFinite(endedAt) && startedAt > 0 && endedAt > 0 && (
- Started {new Date(startedAt).toISOString()} - Ended {new Date(endedAt).toISOString()} + + Started {new Date(startedAt).toLocaleTimeString()} + + + Ended {new Date(endedAt).toLocaleTimeString()} +
)}
@@ -833,38 +876,77 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) { const treeRef = useRef(null) const [searchQuery, setSearchQuery] = useState('') + const [treePaneWidth, setTreePaneWidth] = useState(DEFAULT_TREE_PANE_WIDTH) + const treePaneWidthRef = useRef(DEFAULT_TREE_PANE_WIDTH) + treePaneWidthRef.current = treePaneWidth + const isResizingRef = useRef(false) + const startXRef = useRef(0) + const startWidthRef = useRef(0) - const { normalizedSpans, allIds, totalDuration, runStartMs, firstRootId, blockCount } = - useMemo(() => { - const sorted = normalizeAndSort(traceSpans ?? []) - let earliest = Number.POSITIVE_INFINITY - let latest = 0 - for (const span of sorted) { - const s = parseTime(span.startTime) - const e = parseTime(span.endTime) - if (s < earliest) earliest = s - if (e > latest) latest = e - } - const ids = collectAllIds(sorted) - const count = ids.length - const runStart = earliest !== Number.POSITIVE_INFINITY ? earliest : 0 - return { - normalizedSpans: sorted, - allIds: ids, - totalDuration: latest > runStart ? latest - runStart : 0, - runStartMs: runStart, - firstRootId: sorted.length > 0 ? getSpanId(sorted[0]) : null, - blockCount: count, - } - }, [traceSpans]) + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current) return + const delta = e.clientX - startXRef.current + setTreePaneWidth( + Math.max(MIN_TREE_PANE_WIDTH, Math.min(MAX_TREE_PANE_WIDTH, startWidthRef.current + delta)) + ) + } + const handleMouseUp = () => { + if (!isResizingRef.current) return + isResizingRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + }, []) + + const { + normalizedSpans, + allIds, + totalDuration, + runStartMs, + firstRootId, + firstErrorId, + blockCount, + } = useMemo(() => { + const sorted = normalizeAndSort(traceSpans ?? []) + let earliest = Number.POSITIVE_INFINITY + let latest = 0 + for (const span of sorted) { + const s = parseTime(span.startTime) + const e = parseTime(span.endTime) + if (s < earliest) earliest = s + if (e > latest) latest = e + } + const ids = collectAllIds(sorted) + const count = ids.length + const runStart = earliest !== Number.POSITIVE_INFINITY ? earliest : 0 + const firstError = findLeafErrorSpan(sorted) + return { + normalizedSpans: sorted, + allIds: ids, + totalDuration: latest > runStart ? latest - runStart : 0, + runStartMs: runStart, + firstRootId: sorted.length > 0 ? getSpanId(sorted[0]) : null, + firstErrorId: firstError ? getSpanId(firstError) : null, + blockCount: count, + } + }, [traceSpans]) const [expandedNodes, setExpandedNodes] = useState>(() => new Set(allIds)) - const [selectedId, setSelectedId] = useState(firstRootId) + const [selectedId, setSelectedId] = useState(firstErrorId ?? firstRootId) const [prevAllIds, setPrevAllIds] = useState(allIds) if (prevAllIds !== allIds) { setPrevAllIds(allIds) setExpandedNodes(new Set(allIds)) - setSelectedId(firstRootId) + setSelectedId(firstErrorId ?? firstRootId) } const matchingIds = useMemo( @@ -902,9 +984,6 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) }) }, []) - const handleExpandAll = useCallback(() => setExpandedNodes(new Set(allIds)), [allIds]) - const handleCollapseAll = useCallback(() => setExpandedNodes(new Set()), []) - useEffect(() => { const handler = (e: KeyboardEvent) => { // Ignore while typing in inputs / contentEditable (filter box, etc.). @@ -972,22 +1051,37 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps)
{/* Header strip */}
- {runStatus === 'error' ? 'Error' : 'Success'} - + + {firstErrorId && ( + + )} {formatDuration(totalDuration, { precision: 2 }) || '—'} {blockCount} {blockCount === 1 ? 'span' : 'spans'} + {(() => { + const rootCost = formatCostAmount(normalizedSpans[0]?.cost?.total) + return rootCost ? ( + + {rootCost} + + ) : null + })()}
@@ -1005,7 +1099,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) type='button' variant='ghost' className='!p-1' - onClick={handleExpandAll} + onClick={() => setExpandedNodes(new Set(allIds))} aria-label='Expand all' > @@ -1019,7 +1113,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) type='button' variant='ghost' className='!p-1' - onClick={handleCollapseAll} + onClick={() => setExpandedNodes(new Set())} aria-label='Collapse all' > @@ -1034,8 +1128,8 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps)
{flatList.length === 0 && ( @@ -1060,6 +1154,19 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) ) })}
+ {/* Resize handle */} +
{ + isResizingRef.current = true + startXRef.current = e.clientX + startWidthRef.current = treePaneWidthRef.current + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + }} + > +
+
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index c4c8632e249..83a8c2ff2e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -1,29 +1,30 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import { formatDuration } from '@sim/utils/formatting' -import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react' +import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Eye, Search, X } from 'lucide-react' import { createPortal } from 'react-dom' import { Button, Code, + Copy as CopyIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, + Redo, + Search as SearchIcon, SModalTabs, SModalTabsContent, SModalTabsList, SModalTabsTrigger, Tooltip, } from '@/components/emcn' -import { Copy as CopyIcon, Redo, Search as SearchIcon } from '@/components/emcn/icons' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { cn } from '@/lib/core/utils/cn' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' -import type { TraceSpan } from '@/lib/logs/types' import { workflowBorderColor } from '@/lib/workspaces/colors' import { ExecutionSnapshot, @@ -39,7 +40,6 @@ import { StatusBadge, TriggerBadge, } from '@/app/workspace/[workspaceId]/logs/utils' -import { getBlock } from '@/blocks/registry' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { usePermissionConfig } from '@/hooks/use-permission-config' import { formatCost } from '@/providers/utils' @@ -75,24 +75,20 @@ export const WorkflowOutputSection = memo( const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) - const handleContextMenu = useCallback((e: React.MouseEvent) => { + function handleContextMenu(e: React.MouseEvent) { e.preventDefault() e.stopPropagation() setContextMenuPosition({ x: e.clientX, y: e.clientY }) setIsContextMenuOpen(true) - }, []) - - const closeContextMenu = useCallback(() => { - setIsContextMenuOpen(false) - }, []) + } - const handleCopy = useCallback(() => { + function handleCopy() { navigator.clipboard.writeText(jsonString) setCopied(true) if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500) - closeContextMenu() - }, [jsonString, closeContextMenu]) + setIsContextMenuOpen(false) + } useEffect(() => { return () => { @@ -100,10 +96,10 @@ export const WorkflowOutputSection = memo( } }, []) - const handleSearch = useCallback(() => { + function handleSearch() { activateSearch() - closeContextMenu() - }, [activateSearch, closeContextMenu]) + setIsContextMenuOpen(false) + } return (
@@ -214,7 +210,11 @@ export const WorkflowOutputSection = memo( {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} {typeof document !== 'undefined' && createPortal( - + setIsContextMenuOpen(false)} + modal={false} + >
prev.output === next.output ) -/** - * Compact horizontal timeline showing each block's execution as a proportional colored segment. - */ -function ExecutionTimeline({ traceSpans }: { traceSpans: TraceSpan[] }) { - const { segments, totalDuration } = useMemo(() => { - if (!traceSpans || traceSpans.length === 0) return { segments: [], totalDuration: 0 } - - const rootSpan = traceSpans[0] - if (!rootSpan) return { segments: [], totalDuration: 0 } - - const rootStart = new Date(rootSpan.startTime).getTime() - const rootEnd = new Date(rootSpan.endTime).getTime() - const total = rootSpan.duration || rootEnd - rootStart - if (total <= 0) return { segments: [], totalDuration: 0 } - - const children = (rootSpan.children || []).filter( - (c) => c.type.toLowerCase() !== 'workflow' && c.name !== 'Start' - ) - const segs = children.map((child) => { - const childStart = new Date(child.startTime).getTime() - const childEnd = new Date(child.endTime).getTime() - const childDuration = child.duration || childEnd - childStart - const startPct = ((childStart - rootStart) / total) * 100 - const widthPct = (childDuration / total) * 100 - - const lowerType = child.type.toLowerCase() - const blockType = lowerType === 'model' ? 'agent' : lowerType - const blockConfig = getBlock(blockType) - const color = - lowerType === 'workflow' - ? '#6366F1' - : lowerType === 'loop' || lowerType === 'loop-iteration' - ? '#F59E0B' - : lowerType === 'parallel' || lowerType === 'parallel-iteration' - ? '#10B981' - : (blockConfig?.bgColor ?? '#6366F1') - - return { - name: child.name, - color, - startPct: Math.max(0, Math.min(100, startPct)), - widthPct: Math.max(0.5, Math.min(100, widthPct)), - duration: childDuration, - status: child.status, - } - }) - - return { segments: segs, totalDuration: total } - }, [traceSpans]) - - if (segments.length === 0) return null - - return ( -
-
- Execution - - {formatDuration(totalDuration, { precision: 2 })} - -
-
- {segments.map((seg, i) => ( -
- ))} -
-
- {segments.map((seg, i) => ( -
-
- {seg.name} - - {formatDuration(seg.duration, { precision: 1 })} - -
- ))} -
-
- ) -} - interface LogDetailsProps { /** The log to display details for */ log: WorkflowLog | null @@ -479,7 +388,7 @@ export const LogDetails = memo(function LogDetails({ {/* Resize Handle - positioned outside the panel */} {isOpen && (
@@ -501,6 +411,22 @@ export const LogDetails = memo(function LogDetails({

Log Details

+ {log?.status === 'failed' && (log?.workflow?.id || log?.workflowId) && ( + + + + + Retry + + )} - {log?.status === 'failed' && (log?.workflow?.id || log?.workflowId) && ( - - - - - Retry - - )} @@ -562,30 +472,20 @@ export const LogDetails = memo(function LogDetails({ className='mt-4 min-h-0 flex-1 overflow-y-auto' >
- {/* Execution Timeline */} - {isWorkflowExecutionLog && - log.executionData?.traceSpans && - !permissionConfig.hideTraceSpans && ( - - )} - - {/* Details Section */} -
- {/* Timestamp */} -
- + {/* Timestamp + Workflow header */} +
+
+ Timestamp - + {formattedTimestamp ? `${formattedTimestamp.compactDate} ${formattedTimestamp.compactTime}` - : 'N/A'} + : '—'}
- - {/* Workflow / Job */} -
- +
+ {log.trigger === 'mothership' ? 'Job' : 'Workflow'}
@@ -606,7 +506,7 @@ export const LogDetails = memo(function LogDetails({ /> ) })()} - + {log.trigger === 'mothership' ? log.jobTitle || 'Untitled Job' : log.workflow?.name || @@ -614,11 +514,14 @@ export const LogDetails = memo(function LogDetails({
+
+ {/* Details Section */} +
{/* Run ID — click to copy */} {log.executionId && (
{ navigator.clipboard.writeText(log.executionId!) if (copiedRunIdTimerRef.current) clearTimeout(copiedRunIdTimerRef.current) @@ -632,21 +535,14 @@ export const LogDetails = memo(function LogDetails({ Run ID - + {copiedRunId ? 'Copied!' : log.executionId}
)} {/* Level */} -
+
Level @@ -654,7 +550,7 @@ export const LogDetails = memo(function LogDetails({
{/* Trigger */} -
+
Trigger @@ -668,7 +564,7 @@ export const LogDetails = memo(function LogDetails({
{/* Duration */} -
+
Duration @@ -679,7 +575,7 @@ export const LogDetails = memo(function LogDetails({ {/* Version */} {log.deploymentVersion && ( -
+
Version @@ -691,18 +587,21 @@ export const LogDetails = memo(function LogDetails({
)} - {/* Workflow State */} + {/* Snapshot */} {showWorkflowState && ( -
setIsExecutionSnapshotOpen(true)} - > +
- Workflow State + Snapshot - +
)}
@@ -741,8 +640,8 @@ export const LogDetails = memo(function LogDetails({ {/* Cost Breakdown */} {hasCostInfo && ( -
-
+
+
Base Run @@ -750,7 +649,7 @@ export const LogDetails = memo(function LogDetails({ {formatCost(BASE_EXECUTION_CHARGE)}
-
+
Model Input @@ -758,7 +657,7 @@ export const LogDetails = memo(function LogDetails({ {formatCost(log.cost?.input || 0)}
-
+
Model Output @@ -774,7 +673,7 @@ export const LogDetails = memo(function LogDetails({ ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) : 0 return totalToolCost > 0 ? ( -
+
Tool Usage @@ -784,8 +683,7 @@ export const LogDetails = memo(function LogDetails({
) : null })()} -
-
+
Total @@ -793,7 +691,7 @@ export const LogDetails = memo(function LogDetails({ {formatCost(log.cost?.total || 0)}
-
+
Tokens @@ -802,7 +700,7 @@ export const LogDetails = memo(function LogDetails({ {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out
-
+

Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus model and tool usage. diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index f9387e12534..0a4b50ff53b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -217,11 +217,9 @@ function CollapsibleSection({

@@ -663,7 +661,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro disabled className='mb-1' /> -
+
Enter a number between 1 and {config.maxIterations}
@@ -1091,7 +1089,7 @@ function PreviewEditorContent({ const subflowName = block.name || (isLoop ? 'Loop' : 'Parallel') return ( -
+
{/* Header - styled like subflow header */}
+
@@ -1180,7 +1178,7 @@ function PreviewEditorContent({ : 'gray' return ( -
+
{/* Header - styled like editor */}
{block.type !== 'note' && ( @@ -1188,10 +1186,7 @@ function PreviewEditorContent({ className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm' style={{ backgroundColor: blockConfig.bgColor }} > - +
)} @@ -1394,7 +1389,7 @@ function PreviewEditorContent({ className='h-[18px] w-[18px] animate-spin rounded-full' style={{ background: - 'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)', + 'conic-gradient(from 0deg, var(--text-tertiary) 0deg 120deg, transparent 120deg 180deg, var(--text-tertiary) 180deg 300deg, transparent 300deg 360deg)', mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))', WebkitMask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index ac19ec9d1d1..4d3e7fd45f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -1,6 +1,7 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import type React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { ArrowLeft } from 'lucide-react' import { Button, Tooltip } from '@/components/emcn' import { redactApiKeys } from '@/lib/core/security/redaction' @@ -126,8 +127,14 @@ interface PreviewProps { initialSelectedBlockId?: string | null /** Whether to auto-select the leftmost block on mount */ autoSelectLeftmost?: boolean + /** Whether to show the close (X) button on the block detail panel */ + showBlockCloseButton?: boolean } +const MIN_PANEL_WIDTH = 280 +const MAX_PANEL_WIDTH = 600 +const DEFAULT_PANEL_WIDTH = 320 + /** * Main preview component that combines PreviewCanvas with PreviewEditor * and handles nested workflow navigation via a stack. @@ -151,7 +158,47 @@ export function Preview({ showBorder = false, initialSelectedBlockId, autoSelectLeftmost = true, + showBlockCloseButton = true, }: PreviewProps) { + const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH) + const panelWidthRef = useRef(DEFAULT_PANEL_WIDTH) + panelWidthRef.current = panelWidth + const isResizingRef = useRef(false) + const startXRef = useRef(0) + const startWidthRef = useRef(0) + + function handleResizeMouseDown(e: React.MouseEvent) { + isResizingRef.current = true + startXRef.current = e.clientX + startWidthRef.current = panelWidthRef.current + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + } + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current) return + const delta = startXRef.current - e.clientX + setPanelWidth( + Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, startWidthRef.current + delta)) + ) + } + const handleMouseUp = () => { + if (!isResizingRef.current) return + isResizingRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + }, []) + const [pinnedBlockId, setPinnedBlockId] = useState(() => { if (initialSelectedBlockId) return initialSelectedBlockId if (autoSelectLeftmost) { @@ -173,67 +220,55 @@ export function Preview({ return buildBlockExecutions(rootTraceSpans) }, [providedBlockExecutions, rootTraceSpans]) - const blockExecutions = useMemo(() => { - if (workflowStack.length > 0) { - return workflowStack[workflowStack.length - 1].blockExecutions - } - return rootBlockExecutions - }, [workflowStack, rootBlockExecutions]) - - const workflowState = useMemo(() => { - if (workflowStack.length > 0) { - return workflowStack[workflowStack.length - 1].workflowState - } - return rootWorkflowState - }, [workflowStack, rootWorkflowState]) - - const isExecutionMode = useMemo(() => { - return Object.keys(blockExecutions).length > 0 - }, [blockExecutions]) - - const handleDrillDown = useCallback( - (blockId: string, childWorkflowState: WorkflowState) => { - const blockExecution = blockExecutions[blockId] - const childTraceSpans = extractChildTraceSpans(blockExecution) - const childBlockExecutions = buildBlockExecutions(childTraceSpans) - - const workflowName = - childWorkflowState.metadata?.name || - (blockExecution?.output as { childWorkflowName?: string } | undefined)?.childWorkflowName || - 'Nested Workflow' - - setWorkflowStack((prev) => [ - ...prev, - { - workflowState: childWorkflowState, - traceSpans: childTraceSpans, - blockExecutions: childBlockExecutions, - workflowName, - }, - ]) - - const leftmostId = getLeftmostBlockId(childWorkflowState) - setPinnedBlockId(leftmostId) - }, - [blockExecutions] - ) + const currentStackEntry = + workflowStack.length > 0 ? workflowStack[workflowStack.length - 1] : null + const blockExecutions = currentStackEntry + ? currentStackEntry.blockExecutions + : rootBlockExecutions + const workflowState = currentStackEntry ? currentStackEntry.workflowState : rootWorkflowState + + const isExecutionMode = Object.keys(blockExecutions).length > 0 + + function handleDrillDown(blockId: string, childWorkflowState: WorkflowState) { + const blockExecution = blockExecutions[blockId] + const childTraceSpans = extractChildTraceSpans(blockExecution) + const childBlockExecutions = buildBlockExecutions(childTraceSpans) + + const workflowName = + childWorkflowState.metadata?.name || + (blockExecution?.output as { childWorkflowName?: string } | undefined)?.childWorkflowName || + 'Nested Workflow' + + setWorkflowStack((prev) => [ + ...prev, + { + workflowState: childWorkflowState, + traceSpans: childTraceSpans, + blockExecutions: childBlockExecutions, + workflowName, + }, + ]) + + const leftmostId = getLeftmostBlockId(childWorkflowState) + setPinnedBlockId(leftmostId) + } - const handleGoBack = useCallback(() => { + function handleGoBack() { setWorkflowStack((prev) => prev.slice(0, -1)) setPinnedBlockId(null) - }, []) + } - const handleNodeClick = useCallback((blockId: string) => { + function handleNodeClick(blockId: string) { setPinnedBlockId(blockId) - }, []) + } - const handlePaneClick = useCallback(() => { + function handlePaneClick() { setPinnedBlockId(null) - }, []) + } - const handleEditorClose = useCallback(() => { + function handleEditorClose() { setPinnedBlockId(null) - }, []) + } const isNested = workflowStack.length > 0 @@ -289,19 +324,26 @@ export function Preview({
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && ( - +
+ {/* Left-edge resize handle */} +
+ +
)}
) diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 6ecb0f74f32..15beff689e7 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -25,9 +25,9 @@ export const logKeys = { [...logKeys.lists(), workspaceId ?? '', filters] as const, details: () => [...logKeys.all, 'detail'] as const, detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const, - statsAll: () => [...logKeys.all, 'stats'] as const, - stats: (workspaceId: string | undefined, filters: object) => - [...logKeys.statsAll(), workspaceId ?? '', filters] as const, + stats: () => [...logKeys.all, 'stats'] as const, + stat: (workspaceId: string | undefined, filters: object) => + [...logKeys.stats(), workspaceId ?? '', filters] as const, executionSnapshots: () => [...logKeys.all, 'executionSnapshot'] as const, executionSnapshot: (executionId: string | undefined) => [...logKeys.executionSnapshots(), executionId ?? ''] as const, @@ -223,7 +223,7 @@ export function useDashboardStats( options?: UseDashboardStatsOptions ) { return useQuery({ - queryKey: logKeys.stats(workspaceId, filters), + queryKey: logKeys.stat(workspaceId, filters), queryFn: ({ signal }) => fetchDashboardStats(workspaceId as string, filters, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, @@ -328,7 +328,7 @@ export function useCancelExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) - queryClient.invalidateQueries({ queryKey: logKeys.statsAll() }) + queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) } @@ -359,7 +359,7 @@ export function useRetryExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) - queryClient.invalidateQueries({ queryKey: logKeys.statsAll() }) + queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) } diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index ede01150f78..1c3bc87a4b1 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -236,6 +236,9 @@ function buildChildrenFromTimeSegments( if (typeof segment.ttft === 'number' && segment.ttft >= 0) { modelChild.ttft = segment.ttft } + if (span.model) { + modelChild.model = span.model + } if (segment.provider) { modelChild.provider = segment.provider } From 0680de5bc6ca86ed94a7405fad261d380c024443 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 24 Apr 2026 15:54:37 -0700 Subject: [PATCH 14/28] fix(ui): scroll guard, credentials UX, design token fixes, input padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logs: only scroll-into-view on keyboard nav, not on click selection - resource: stable scrollbar gutter, wider first column - credentials: toast success/error feedback, remove useMemo for personalEnvData, allow editing conflict rows, fix disabled state visibility, use --text-error token - integrations: use --text-error token for error state - input: increase right padding (px-2 → pl-2 pr-3) --- .../components/resource/resource.tsx | 7 +++-- .../app/workspace/[workspaceId]/logs/logs.tsx | 7 ++++- .../credentials/credentials-manager.tsx | 26 +++++++++---------- .../integrations/integrations-manager.tsx | 2 +- .../emcn/components/input/input.tsx | 2 +- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 32792f1f367..9ba68c197d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -313,7 +313,10 @@ export const ResourceTable = memo(function ResourceTable({
-
+
@@ -562,7 +565,7 @@ const ResourceColGroup = memo(function ResourceColGroup({ key={col.id} style={ colIdx === 0 - ? { minWidth: 200 * (col.widthMultiplier ?? 1) } + ? { width: 400 * (col.widthMultiplier ?? 1) } : { width: 160 * (col.widthMultiplier ?? 1) } } /> diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index aa10477d8dc..a6947671b53 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -290,6 +290,7 @@ export default function Logs() { const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) const selectedLogIdRef = useRef(null) + const shouldScrollIntoViewRef = useRef(false) const logsRefetchRef = useRef<() => void>(() => {}) const activeLogRefetchRef = useRef<() => void>(() => {}) const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) @@ -467,6 +468,7 @@ export default function Logs() { const idx = selectedLogIndexRef.current const currentLogs = logsRef.current if (idx < currentLogs.length - 1) { + shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id }) } }, []) @@ -474,6 +476,7 @@ export default function Logs() { const handleNavigatePrev = useCallback(() => { const idx = selectedLogIndexRef.current if (idx > 0) { + shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id }) } }, []) @@ -594,7 +597,8 @@ export default function Logs() { }) useEffect(() => { - if (!selectedLogId) return + if (!selectedLogId || !shouldScrollIntoViewRef.current) return + shouldScrollIntoViewRef.current = false const row = document.querySelector(`[data-row-id="${selectedLogId}"]`) as HTMLElement | null if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) @@ -713,6 +717,7 @@ export default function Logs() { if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault() + shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id }) return } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index 42cda8def1c..a9a7b2e29bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -22,6 +22,7 @@ import { Textarea, Tooltip, Trash, + toast, } from '@/components/emcn' import { Input } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' @@ -60,7 +61,6 @@ const logger = createLogger('SecretsManager') const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto_auto] items-center' const COL_SPAN_ALL = 'col-span-5' -const CONFLICT_CLASS = 'border-[var(--text-error)] bg-[var(--error-muted)]' const ROLE_OPTIONS = [ { value: 'member', label: 'Member' }, @@ -402,7 +402,6 @@ export function CredentialsManager() { const isWorkspaceAdmin = workspacePermissions?.viewer?.isAdmin ?? false const isLoading = isPersonalLoading || isWorkspaceLoading - const variables = useMemo(() => personalEnvData || {}, [personalEnvData]) const [envVars, setEnvVars] = useState([]) const [newWorkspaceRows, setNewWorkspaceRows] = useState([ @@ -591,7 +590,7 @@ export function CredentialsManager() { useEffect(() => { if (hasSavedRef.current) return - const existingVars = Object.values(variables) + const existingVars = Object.values(personalEnvData || {}) const initialVars = [ ...existingVars.map((envVar) => ({ ...envVar, @@ -601,7 +600,7 @@ export function CredentialsManager() { ] initialVarsRef.current = JSON.parse(JSON.stringify(initialVars)) setEnvVars(JSON.parse(JSON.stringify(initialVars))) - }, [variables]) + }, [personalEnvData]) useEffect(() => { if (!workspaceEnvData) return @@ -1041,11 +1040,15 @@ export function CredentialsManager() { setWorkspaceVars(mergedWorkspaceVars) setNewWorkspaceRows([createEmptyEnvVar()]) + if (mutations.length > 0) { + toast.success('Secrets saved') + } } catch (error) { hasSavedRef.current = false initialVarsRef.current = prevInitialVars initialWorkspaceVarsRef.current = prevInitialWorkspaceVars logger.error('Failed to save environment variables:', error) + toast.error('Failed to save secrets') } finally { if (mutations.length > 0) { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }) @@ -1095,7 +1098,7 @@ export function CredentialsManager() { onFocus={(e) => e.target.removeAttribute('readOnly')} className={cn( 'h-9', - isConflict && CONFLICT_CLASS, + isConflict && 'border-[var(--text-error)]', keyError && 'border-[var(--text-error)]' )} /> @@ -1115,8 +1118,6 @@ export function CredentialsManager() { onBlur={() => setFocusedValueIndex(null)} onPaste={(e) => handlePaste(e, originalIndex)} placeholder={isConflict ? 'Workspace override active' : 'Enter value'} - disabled={isConflict} - aria-disabled={isConflict} name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`} autoComplete='off' autoCapitalize='off' @@ -1125,12 +1126,11 @@ export function CredentialsManager() { style={maskedValueStyle} className={cn( 'h-9', - !isComplete && 'col-span-2', - isConflict && 'cursor-not-allowed', - isConflict && CONFLICT_CLASS + (!isComplete || isConflict) && 'col-span-2', + isConflict && 'cursor-not-allowed opacity-50' )} /> - {isComplete && ( + {isComplete && !isConflict && ( - - Retry - - )} + {log?.status === 'failed' && + (log?.workflow?.id || log?.workflowId) && + log?.trigger !== 'mothership' && ( + + + + + Retry + + )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx index 998709cd615..ab3c2c50e2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx @@ -801,15 +801,16 @@ export function IntegrationsManager() { {filteredServices.map((service) => { const config = getServiceConfigByProviderId(service.value) return ( - + ) })} {filteredServices.length === 0 && ( @@ -844,17 +845,18 @@ export function IntegrationsManager() { <>
- + Connect{' '} {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)} @@ -970,17 +972,18 @@ export function IntegrationsManager() { <>
- + Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)} @@ -1146,10 +1149,10 @@ export function IntegrationsManager() { ? This action cannot be undone.

{deleteError && ( -
+
- -

{deleteError}

+ +

{deleteError}

)} @@ -1240,9 +1243,10 @@ export function IntegrationsManager() { Display Name - + {copyIdSuccess ? 'Copied!' : 'Copy credential ID'} diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 15beff689e7..edfd58f13d5 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -170,7 +170,6 @@ export function useLogDetail(logId: string | undefined, options?: UseLogDetailOp enabled: Boolean(logId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, staleTime: 30 * 1000, - placeholderData: keepPreviousData, }) } From 2a3cbc7f87c8863fa9afc82d9582acaa3d59ce7c Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 13:48:46 -0700 Subject: [PATCH 19/28] fix(trace-spans): extend final model segment by position not by stale constant name --- apps/sim/lib/logs/execution/trace-spans/span-factory.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index 1c3bc87a4b1..30329a8b9b5 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -14,8 +14,6 @@ import type { const logger = createLogger('SpanFactory') -const STREAMING_SEGMENT_NAME = 'Streaming response' - /** A BlockLog that has already passed the id/type validity check. */ type ValidBlockLog = BlockLog & { blockType: string } @@ -168,9 +166,10 @@ function buildChildrenFromTimeSegments( let segmentEndTime = new Date(segment.endTime).toISOString() let segmentDuration = segment.duration - // Streaming segments sometimes close before the block ends; extend the - // trailing streaming segment to the block endTime so the bar fills. - if (segment.name === STREAMING_SEGMENT_NAME && log.endedAt) { + // The final model segment sometimes closes before the block ends (e.g. when + // post-processing runs after the stream). Extend it to the block endTime so + // the Gantt bar fills to the edge rather than leaving a trailing gap. + if (index === segments.length - 1 && segment.type === 'model' && log.endedAt) { const blockEndMs = new Date(log.endedAt).getTime() const segmentEndMs = new Date(segment.endTime).getTime() if (blockEndMs > segmentEndMs) { From 9acd966bf1d7d0ec67ea2b12caf61f5c8e6b5307 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 14:00:03 -0700 Subject: [PATCH 20/28] fix(modal): restore sidebar-width padding on non-workflow pages --- apps/sim/components/emcn/components/modal/modal.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index a04b5009eb6..dcca3f3cb69 100644 --- a/apps/sim/components/emcn/components/modal/modal.tsx +++ b/apps/sim/components/emcn/components/modal/modal.tsx @@ -157,11 +157,11 @@ const ModalContent = React.forwardRef<
Date: Tue, 28 Apr 2026 15:47:01 -0700 Subject: [PATCH 21/28] fix(secrets): eliminate slow save by parallelizing DB ops and fixing stuck button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequential per-variable, per-workspace DB round-trips in syncPersonalEnvCredentialsForUser caused O(W×K) latency (800–1600ms for 10 workspaces). Replaced with parallel workspace processing and batched upserts. Also parallelized secret decryption in the GET handler. On the client, removed the changeToken bug that left the Save button permanently disabled after a failed save, split the shared hasSavedRef into two independent flags to eliminate ordering races, and moved ref updates to after mutation success so optimistic state can never get stuck. --- apps/sim/app/api/environment/route.ts | 27 +-- .../credentials/credentials-manager.tsx | 138 ++++++------- apps/sim/lib/credentials/environment.ts | 181 ++++++++---------- 3 files changed, 163 insertions(+), 183 deletions(-) diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 7d74c421262..9bd5e3d41c1 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -120,17 +120,22 @@ export const GET = withRouteHandler(async (request: Request) => { } const encryptedVariables = result[0].variables as Record - const decryptedVariables: Record = {} - - for (const [key, encryptedValue] of Object.entries(encryptedVariables)) { - try { - const { decrypted } = await decryptSecret(encryptedValue) - decryptedVariables[key] = { key, value: decrypted } - } catch (error) { - logger.error(`[${requestId}] Error decrypting variable ${key}`, error) - decryptedVariables[key] = { key, value: '' } - } - } + + const decryptedEntries = await Promise.all( + Object.entries(encryptedVariables).map(async ([key, encryptedValue]) => { + try { + const { decrypted } = await decryptSecret(encryptedValue) + return [key, { key, value: decrypted }] as const + } catch (error) { + logger.error(`[${requestId}] Error decrypting variable ${key}`, error) + return [key, { key, value: '' }] as const + } + }) + ) + const decryptedVariables = Object.fromEntries(decryptedEntries) as Record< + string, + EnvironmentVariable + > return NextResponse.json({ data: decryptedVariables }, { status: 200 }) } catch (error: any) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index a9a7b2e29bf..fd04a8e7b3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -413,8 +413,6 @@ export function CredentialsManager() { const [workspaceVars, setWorkspaceVars] = useState>({}) const [renamingKey, setRenamingKey] = useState(null) const [pendingKeyValue, setPendingKeyValue] = useState('') - const [changeToken, setChangeToken] = useState(0) - const [selectedCredentialId, setSelectedCredentialId] = useState(null) const [prevSelectedCredentialId, setPrevSelectedCredentialId] = useState< string | null | undefined @@ -431,7 +429,8 @@ export function CredentialsManager() { const scrollContainerRef = useRef(null) const initialVarsRef = useRef([]) const hasChangesRef = useRef(false) - const hasSavedRef = useRef(false) + const hasSavedPersonalRef = useRef(false) + const hasSavedWorkspaceRef = useRef(false) const shouldBlockNavRef = useRef(false) const pendingNavigationUrlRef = useRef(null) @@ -558,7 +557,7 @@ export function CredentialsManager() { if (newWorkspaceRows.some((row) => row.key && row.value)) return true return false - }, [envVars, workspaceVars, newWorkspaceRows, changeToken]) + }, [envVars, workspaceVars, newWorkspaceRows]) const hasConflicts = useMemo(() => { return envVars.some((envVar) => !!envVar.key && allWorkspaceKeys.has(envVar.key)) @@ -588,7 +587,10 @@ export function CredentialsManager() { useEffect(() => () => resetNavGuard(), [resetNavGuard]) useEffect(() => { - if (hasSavedRef.current) return + if (hasSavedPersonalRef.current) { + hasSavedPersonalRef.current = false + return + } const existingVars = Object.values(personalEnvData || {}) const initialVars = [ @@ -604,12 +606,12 @@ export function CredentialsManager() { useEffect(() => { if (!workspaceEnvData) return - if (hasSavedRef.current) { - hasSavedRef.current = false - } else { - setWorkspaceVars(workspaceEnvData.workspace || {}) - initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {} + if (hasSavedWorkspaceRef.current) { + hasSavedWorkspaceRef.current = false + return } + setWorkspaceVars(workspaceEnvData.workspace || {}) + initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {} }, [workspaceEnvData]) const scrollToBottom = useCallback(() => { @@ -967,86 +969,86 @@ export function CredentialsManager() { const handleSave = async () => { if (isListSaving) return - const prevInitialVars = [...initialVarsRef.current] - const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current } const mutations: Promise[] = [] - try { - setShowUnsavedChanges(false) - hasSavedRef.current = true - - const mergedWorkspaceVars = { ...workspaceVars } - for (const row of newWorkspaceRows) { - if (row.key && row.value) { - mergedWorkspaceVars[row.key] = row.value - } + setShowUnsavedChanges(false) + + const mergedWorkspaceVars = { ...workspaceVars } + for (const row of newWorkspaceRows) { + if (row.key && row.value) { + mergedWorkspaceVars[row.key] = row.value } + } - initialWorkspaceVarsRef.current = { ...mergedWorkspaceVars } - initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value))) + const validVariables = envVars + .filter((v) => v.key && v.value) + .reduce>((acc, { key, value }) => ({ ...acc, [key]: value }), {}) - setChangeToken((prev) => prev + 1) + const before = initialWorkspaceVarsRef.current + const after = mergedWorkspaceVars + const toUpsert: Record = {} + const toDelete: string[] = [] - const validVariables = envVars - .filter((v) => v.key && v.value) - .reduce>((acc, { key, value }) => ({ ...acc, [key]: value }), {}) + for (const [k, v] of Object.entries(after)) { + if (!(k in before) || before[k] !== v) { + toUpsert[k] = v + } + } - const before = prevInitialWorkspaceVars - const after = mergedWorkspaceVars - const toUpsert: Record = {} - const toDelete: string[] = [] + for (const k of Object.keys(before)) { + if (!(k in after)) toDelete.push(k) + } - for (const [k, v] of Object.entries(after)) { - if (!(k in before) || before[k] !== v) { - toUpsert[k] = v - } + const personalChanged = (() => { + const initialMap = new Map( + initialVarsRef.current.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) + ) + const currentKeys = Object.keys(validVariables) + if (initialMap.size !== currentKeys.length) return true + for (const [key, value] of Object.entries(validVariables)) { + if (initialMap.get(key) !== value) return true } + return false + })() - for (const k of Object.keys(before)) { - if (!(k in after)) toDelete.push(k) - } + const workspaceChanged = + workspaceId && (Object.keys(toUpsert).length > 0 || toDelete.length > 0) - const personalChanged = (() => { - const initialMap = new Map( - prevInitialVars.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) - ) - const currentKeys = Object.keys(validVariables) - if (initialMap.size !== currentKeys.length) return true - for (const [key, value] of Object.entries(validVariables)) { - if (initialMap.get(key) !== value) return true - } - return false - })() - - if (personalChanged) { - mutations.push(savePersonalMutation.mutateAsync({ variables: validVariables })) - } - if (workspaceId && (Object.keys(toUpsert).length || toDelete.length)) { - mutations.push( - (async () => { - if (Object.keys(toUpsert).length) { - await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert }) - } - if (toDelete.length) { - await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete }) - } - })() - ) - } + if (personalChanged) { + mutations.push(savePersonalMutation.mutateAsync({ variables: validVariables })) + } + if (workspaceChanged) { + mutations.push( + (async () => { + if (Object.keys(toUpsert).length) { + await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert }) + } + if (toDelete.length) { + await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete }) + } + })() + ) + } + + hasSavedPersonalRef.current = personalChanged + hasSavedWorkspaceRef.current = Boolean(workspaceChanged) + try { const results = await Promise.allSettled(mutations) const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected') if (firstFailure) throw firstFailure.reason + initialWorkspaceVarsRef.current = { ...mergedWorkspaceVars } + initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value))) + setWorkspaceVars(mergedWorkspaceVars) setNewWorkspaceRows([createEmptyEnvVar()]) if (mutations.length > 0) { toast.success('Secrets saved') } } catch (error) { - hasSavedRef.current = false - initialVarsRef.current = prevInitialVars - initialWorkspaceVarsRef.current = prevInitialWorkspaceVars + hasSavedPersonalRef.current = false + hasSavedWorkspaceRef.current = false logger.error('Failed to save environment variables:', error) toast.error('Failed to save secrets') } finally { diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index d9dbdce8c9c..0ace9884075 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -61,43 +61,6 @@ export async function getUserWorkspaceIds(userId: string): Promise { return Array.from(workspaceIds) } -async function upsertCredentialAdminMember(credentialId: string, adminUserId: string) { - const now = new Date() - const [existingMembership] = await db - .select({ id: credentialMember.id, joinedAt: credentialMember.joinedAt }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, adminUserId)) - ) - .limit(1) - - if (existingMembership) { - await db - .update(credentialMember) - .set({ - role: 'admin', - status: 'active', - joinedAt: existingMembership.joinedAt ?? now, - invitedBy: adminUserId, - updatedAt: now, - }) - .where(eq(credentialMember.id, existingMembership.id)) - return - } - - await db.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: adminUserId, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: adminUserId, - createdAt: now, - updatedAt: now, - }) -} - async function ensureWorkspaceCredentialMemberships( credentialId: string, memberUserIds: string[], @@ -342,78 +305,88 @@ export async function syncPersonalEnvCredentialsForUser(params: { const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean))) const now = new Date() - for (const workspaceId of workspaceIds) { - const existingCredentials = await db - .select({ - id: credential.id, - envKey: credential.envKey, - }) - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId) - ) - ) - - const existingByKey = new Map( - existingCredentials - .filter((row): row is { id: string; envKey: string } => Boolean(row.envKey)) - .map((row) => [row.envKey, row.id]) - ) - - for (const envKey of normalizedKeys) { - const existingId = existingByKey.get(envKey) - if (existingId) { - await upsertCredentialAdminMember(existingId, userId) - continue + await Promise.all( + workspaceIds.map(async (workspaceId) => { + if (normalizedKeys.length > 0) { + await db + .insert(credential) + .values( + normalizedKeys.map((envKey) => ({ + id: generateId(), + workspaceId, + type: 'env_personal' as const, + displayName: envKey, + envKey, + envOwnerUserId: userId, + createdBy: userId, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoNothing() } - const createdId = generateId() - try { - await db.insert(credential).values({ - id: createdId, - workspaceId, - type: 'env_personal', - displayName: envKey, - envKey, - envOwnerUserId: userId, - createdBy: userId, - createdAt: now, - updatedAt: now, - }) - await upsertCredentialAdminMember(createdId, userId) - } catch (error: unknown) { - const code = getPostgresErrorCode(error) - if (code !== '23505') throw error + const currentCredentials = + normalizedKeys.length > 0 + ? await db + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + inArray(credential.envKey, normalizedKeys) + ) + ) + : [] + + if (currentCredentials.length > 0) { + await db + .insert(credentialMember) + .values( + currentCredentials.map(({ id: credentialId }) => ({ + id: generateId(), + credentialId, + userId, + role: 'admin' as const, + status: 'active' as const, + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [credentialMember.credentialId, credentialMember.userId], + set: { role: 'admin', status: 'active', updatedAt: now }, + }) } - } - if (normalizedKeys.length > 0) { - await db - .delete(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId), - notInArray(credential.envKey, normalizedKeys) + if (normalizedKeys.length > 0) { + await db + .delete(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + notInArray(credential.envKey, normalizedKeys) + ) ) - ) - continue - } - - await db - .delete(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId) - ) - ) - } + } else { + await db + .delete(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId) + ) + ) + } + }) + ) } export async function getAccessibleEnvCredentials( From e8518c88f3397e21096a60464e6bbc8e63493ad4 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 16:05:36 -0700 Subject: [PATCH 22/28] updated sap block --- apps/sim/blocks/blocks/sap_s4hana.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/blocks/blocks/sap_s4hana.ts b/apps/sim/blocks/blocks/sap_s4hana.ts index fc5cd94c66c..30ff9b900b4 100644 --- a/apps/sim/blocks/blocks/sap_s4hana.ts +++ b/apps/sim/blocks/blocks/sap_s4hana.ts @@ -5,16 +5,16 @@ import type { SapProxyResponse } from '@/tools/sap_s4hana/types' export const SapS4HanaBlock: BlockConfig = { type: 'sap_s4hana', - name: 'SAP S/4HANA', - description: 'Read and write SAP S/4HANA Cloud business data via OData', + name: 'SAP S4HANA', + description: 'Read and write SAP S4HANA Cloud business data via OData', authMode: AuthMode.ApiKey, longDescription: - 'Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.', + 'Connect SAP S4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.', docsLink: 'https://docs.sim.ai/tools/sap_s4hana', category: 'tools', integrationType: IntegrationType.Other, tags: ['automation'], - bgColor: '#0A6ED1', + bgColor: '#FFFFFF', icon: SapS4HanaIcon, subBlocks: [ { @@ -700,9 +700,9 @@ export const SapS4HanaBlock: BlockConfig = { title: 'Deployment', type: 'dropdown', options: [ - { label: 'S/4HANA Cloud Public Edition', id: 'cloud_public' }, - { label: 'S/4HANA Cloud Private Edition (RISE)', id: 'cloud_private' }, - { label: 'S/4HANA On-Premise', id: 'on_premise' }, + { label: 'S4HANA Cloud Public Edition', id: 'cloud_public' }, + { label: 'S4HANA Cloud Private Edition (RISE)', id: 'cloud_private' }, + { label: 'S4HANA On-Premise', id: 'on_premise' }, ], value: () => 'cloud_public', required: true, From 2303a710a89652647d636e83d08aa005ec987760 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 16:12:11 -0700 Subject: [PATCH 23/28] fix(sap): remove slash from S4HANA name, set white bgColor, regenerate docs --- .../docs/content/docs/en/tools/sap_s4hana.mdx | 22 +++++++++---------- .../integrations/data/integrations.json | 10 ++++----- .../components/short-input/short-input.tsx | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/docs/content/docs/en/tools/sap_s4hana.mdx b/apps/docs/content/docs/en/tools/sap_s4hana.mdx index 57fde4ba95d..0c8aaf5c745 100644 --- a/apps/docs/content/docs/en/tools/sap_s4hana.mdx +++ b/apps/docs/content/docs/en/tools/sap_s4hana.mdx @@ -1,19 +1,19 @@ --- -title: SAP S/4HANA -description: Read and write SAP S/4HANA Cloud business data via OData +title: SAP S4HANA +description: Read and write SAP S4HANA Cloud business data via OData --- import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} -[SAP S/4HANA](https://www.sap.com/products/erp/s4hana.html) is SAP's flagship intelligent ERP suite, running on the in-memory HANA database. It powers finance, supply chain, procurement, sales, and manufacturing for organizations of every size, and exposes its business data through a broad catalog of OData services on SAP Business Technology Platform (BTP). +[SAP S4HANA](https://www.sap.com/products/erp/s4hana.html) is SAP's flagship intelligent ERP suite, running on the in-memory HANA database. It powers finance, supply chain, procurement, sales, and manufacturing for organizations of every size, and exposes its business data through a broad catalog of OData services on SAP Business Technology Platform (BTP). -With SAP S/4HANA, you can: +With SAP S4HANA, you can: - **Run core business processes**: Manage finance, procurement, sales, logistics, inventory, and manufacturing on a single source of truth. - **Model master data at scale**: Maintain business partners, customers, suppliers, products, and organizational structures across multiple company codes, sales organizations, and plants. @@ -21,22 +21,22 @@ With SAP S/4HANA, you can: - **Govern access cleanly**: Use Communication Arrangements, Communication Systems, and Communication Scopes to scope OAuth client credentials to exactly the services each integration needs. - **Integrate via standard OData**: Every entity supported here speaks OData v2 with consistent paging, filtering, expansion, and ETag-based optimistic concurrency. -In Sim, the SAP S/4HANA integration lets your agents read and write directly against your tenant's OData services using per-tenant OAuth 2.0 client credentials. Agents can list and fetch master data, create and update transactional documents, run stock and material document queries, and execute arbitrary OData v2 calls against any whitelisted Communication Scenario — all routed through a single internal proxy that handles token acquisition, CSRF fetch-and-retry, and OData error normalization. Use it to automate order-to-cash, procure-to-pay, and inventory workflows, keep SAP in sync with the rest of your stack, or trigger downstream agent logic from SAP business events. +In Sim, the SAP S4HANA integration lets your agents read and write directly against your tenant's OData services using per-tenant OAuth 2.0 client credentials. Agents can list and fetch master data, create and update transactional documents, run stock and material document queries, and execute arbitrary OData v2 calls against any whitelisted Communication Scenario — all routed through a single internal proxy that handles token acquisition, CSRF fetch-and-retry, and OData error normalization. Use it to automate order-to-cash, procure-to-pay, and inventory workflows, keep SAP in sync with the rest of your stack, or trigger downstream agent logic from SAP business events. {/* MANUAL-CONTENT-END */} ## Usage Instructions {/* MANUAL-CONTENT-START:usage */} -Connect any SAP S/4HANA tenant — **Cloud Public Edition**, **Cloud Private Edition (RISE)**, or **on-premise** — and read or write business data through the official OData v2 services. Each tool routes through a single internal proxy that handles token acquisition, CSRF fetch-and-retry for write operations, and OData error normalization. +Connect any SAP S4HANA tenant — **Cloud Public Edition**, **Cloud Private Edition (RISE)**, or **on-premise** — and read or write business data through the official OData v2 services. Each tool routes through a single internal proxy that handles token acquisition, CSRF fetch-and-retry for write operations, and OData error normalization. ### Deployment modes Pick the deployment that matches your tenant in the **Deployment** dropdown: -- **S/4HANA Cloud Public Edition** — provide your **BTP subaccount subdomain** and **region** (e.g., `eu10`, `us10`). The host is derived automatically as `{subdomain}-api.s4hana.ondemand.com`, and OAuth tokens are fetched from the matching BTP UAA endpoint. Authentication is OAuth 2.0 client credentials configured in a Communication Arrangement. -- **S/4HANA Cloud Private Edition (RISE)** — provide your **OData Base URL** (e.g., `https://my-tenant.s4hana.cloud.sap`). Authenticate with **OAuth 2.0 client credentials** (provide the tenant's UAA `tokenUrl`, `clientId`, `clientSecret`) or **HTTP Basic** with a Communication User (`username`, `password`). -- **On-premise S/4HANA** — provide your **OData Base URL** (e.g., `https://sap.internal.company.com:44300`). Authenticate with **OAuth 2.0 client credentials** issued by your on-prem identity provider, or **HTTP Basic** with a service user. +- **S4HANA Cloud Public Edition** — provide your **BTP subaccount subdomain** and **region** (e.g., `eu10`, `us10`). The host is derived automatically as `{subdomain}-api.s4hana.ondemand.com`, and OAuth tokens are fetched from the matching BTP UAA endpoint. Authentication is OAuth 2.0 client credentials configured in a Communication Arrangement. +- **S4HANA Cloud Private Edition (RISE)** — provide your **OData Base URL** (e.g., `https://my-tenant.s4hana.cloud.sap`). Authenticate with **OAuth 2.0 client credentials** (provide the tenant's UAA `tokenUrl`, `clientId`, `clientSecret`) or **HTTP Basic** with a Communication User (`username`, `password`). +- **On-premise S4HANA** — provide your **OData Base URL** (e.g., `https://sap.internal.company.com:44300`). Authenticate with **OAuth 2.0 client credentials** issued by your on-prem identity provider, or **HTTP Basic** with a service user. ### What you can do @@ -48,7 +48,7 @@ All update tools accept an optional `ifMatch` ETag. When omitted, `If-Match` def {/* MANUAL-CONTENT-END */} -Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario. +Connect SAP S4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario. diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 1f8c92d35fe..b11c74c5f47 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -11381,11 +11381,11 @@ }, { "type": "sap_s4hana", - "slug": "sap-s-4hana", - "name": "SAP S/4HANA", - "description": "Read and write SAP S/4HANA Cloud business data via OData", - "longDescription": "Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.", - "bgColor": "#0A6ED1", + "slug": "sap-s4hana", + "name": "SAP S4HANA", + "description": "Read and write SAP S4HANA Cloud business data via OData", + "longDescription": "Connect SAP S4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.", + "bgColor": "#FFFFFF", "iconName": "SapS4HanaIcon", "docsUrl": "https://docs.sim.ai/tools/sap_s4hana", "operations": [ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx index 48cdfa2e413..6de90a072e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx @@ -347,7 +347,7 @@ export const ShortInput = memo(function ShortInput({ <> } - className='allow-scroll w-full overflow-auto text-transparent selection:text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden' + className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] selection:text-transparent placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden' readOnly={readOnly} placeholder={placeholder ?? ''} type='text' From 261156f63aad37e1e2250f1d4f666add6868c03e Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 16:20:48 -0700 Subject: [PATCH 24/28] fix(logs): prevent log-row arrow navigation when trace tab is active --- .../logs/components/log-details/log-details.tsx | 7 +++++++ apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 352dec26a74..80fefa9fefe 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -273,6 +273,8 @@ interface LogDetailsProps { onRetryExecution?: () => void /** Whether a retry is currently in progress */ isRetryPending?: boolean + /** Fires when the active tab changes, so the parent can gate its own keyboard handlers */ + onActiveTabChange?: (tab: LogDetailsTab) => void } /** @@ -293,6 +295,7 @@ export const LogDetails = memo(function LogDetails({ hasPrev = false, onRetryExecution, isRetryPending = false, + onActiveTabChange, }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const [activeTab, setActiveTab] = useState('overview') @@ -334,6 +337,10 @@ export const LogDetails = memo(function LogDetails({ const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab + useEffect(() => { + onActiveTabChange?.(resolvedTab) + }, [resolvedTab, onActiveTabChange]) + const workflowOutput = useMemo(() => { const executionData = log?.executionData as | { finalOutput?: Record } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index a7b43b179e5..f517de25426 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -293,6 +293,7 @@ export default function Logs() { const shouldScrollIntoViewRef = useRef(false) const logsRefetchRef = useRef<() => void>(() => {}) const activeLogRefetchRef = useRef<() => void>(() => {}) + const activeLogTabRef = useRef('overview') const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const [activeSort, setActiveSort] = useState<{ @@ -473,6 +474,7 @@ export default function Logs() { const handleCloseSidebar = useCallback(() => { dispatch({ type: 'CLOSE_SIDEBAR' }) + activeLogTabRef.current = 'overview' }, []) const handleLogContextMenu = useCallback( @@ -700,6 +702,8 @@ export default function Logs() { const handleKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return + // When the trace tab is active, arrow keys belong to TraceView's span navigator. + if (activeLogTabRef.current === 'trace') return const currentLogs = logsRef.current const currentIndex = selectedLogIndexRef.current if (currentLogs.length === 0) return @@ -811,6 +815,9 @@ export default function Logs() { hasPrev={selectedLogIndex > 0} onRetryExecution={handleRetrySidebarExecution} isRetryPending={retryExecution.isPending} + onActiveTabChange={(tab) => { + activeLogTabRef.current = tab + }} /> ) From 3f0059b95863c324b2856b4e9163e863b772390a Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 16:31:25 -0700 Subject: [PATCH 25/28] fix(logs): aggregate cost onto workflow root span; stabilize onActiveTabChange callback --- apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 8 +++++--- apps/sim/lib/logs/execution/trace-spans/trace-spans.ts | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index f517de25426..87449ebc701 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -477,6 +477,10 @@ export default function Logs() { activeLogTabRef.current = 'overview' }, []) + const handleActiveTabChange = useCallback((tab: string) => { + activeLogTabRef.current = tab + }, []) + const handleLogContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { e.preventDefault() @@ -815,9 +819,7 @@ export default function Logs() { hasPrev={selectedLogIndex > 0} onRetryExecution={handleRetrySidebarExecution} isRetryPending={retryExecution.isPending} - onActiveTabChange={(tab) => { - activeLogTabRef.current = tab - }} + onActiveTabChange={handleActiveTabChange} /> ) diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index 1f2e2c9503a..815c5159351 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -96,6 +96,8 @@ function wrapInWorkflowRoot( const actualWorkflowDuration = latestEnd - earliestStart addRelativeTimestamps(grouped, earliestStart) + const totalCost = leafSpans.reduce((sum, s) => sum + (s.cost?.total ?? 0), 0) + const workflowSpan: TraceSpan = { id: 'workflow-execution', name: 'Workflow Execution', @@ -105,6 +107,7 @@ function wrapInWorkflowRoot( endTime: new Date(latestEnd).toISOString(), status: grouped.some(hasUnhandledError) ? 'error' : 'success', children: grouped, + ...(totalCost > 0 && { cost: { total: totalCost } }), } return { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration } From 86deb241976b8429c8e2e3f982330edc0345e640 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 17:02:44 -0700 Subject: [PATCH 26/28] improvement(logs): fix Gantt time bounds to walk full span tree; cleanup effects, memos, callbacks, React Query mutations --- apps/sim/app/(auth)/signup/signup-form.tsx | 6 +- .../components/auth-modal/auth-modal.tsx | 43 +++--- .../components/contact/contact-form.tsx | 8 +- .../components/special-tags/special-tags.tsx | 24 ++- .../resource-content/resource-content.tsx | 11 +- .../home/components/user-input/user-input.tsx | 12 +- .../app/workspace/[workspaceId]/home/home.tsx | 131 ++++++++-------- .../rename-document-modal.tsx | 9 +- .../components/line-chart/line-chart.tsx | 10 +- .../components/trace-spans/trace-spans.tsx | 21 +-- .../components/trace-view/trace-view.tsx | 14 +- .../components/log-details/log-details.tsx | 18 ++- .../logs/components/logs-list/logs-list.tsx | 36 ++--- .../logs-toolbar/components/search/search.tsx | 30 ++-- .../components/logs-toolbar/logs-toolbar.tsx | 31 ++-- .../components/subscription/subscription.tsx | 14 +- .../[tableId]/components/table/table.tsx | 62 ++++---- .../import-csv-dialog/import-csv-dialog.tsx | 143 ++++++++---------- .../components/file-upload/file-upload.tsx | 21 +-- .../w/components/sidebar/sidebar.tsx | 29 ++-- .../access-control/hooks/permission-groups.ts | 10 +- apps/sim/hooks/queries/a2a/tasks.ts | 12 +- apps/sim/hooks/queries/chats.ts | 6 +- apps/sim/hooks/queries/deployments.ts | 34 +++-- apps/sim/hooks/queries/invitations.ts | 8 +- apps/sim/hooks/queries/kb/connectors.ts | 12 +- apps/sim/hooks/queries/kb/knowledge.ts | 30 ++-- apps/sim/hooks/queries/mothership-admin.ts | 6 + apps/sim/hooks/queries/organization.ts | 18 +-- apps/sim/hooks/queries/workspace.ts | 6 +- .../copilot/tools/server/table/user-table.ts | 16 +- 31 files changed, 409 insertions(+), 422 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index c721a07291b..2959cf699cf 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -98,11 +98,7 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [formError, setFormError] = useState(null) const turnstileRef = useRef(null) - const [turnstileSiteKey, setTurnstileSiteKey] = useState() - - useEffect(() => { - setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) - }, []) + const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || '' const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false const invalidCallbackRef = useRef(false) diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index d7a213f2499..7b3fb99bc9e 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Loader2, X } from 'lucide-react' import Image from 'next/image' @@ -88,24 +88,21 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal } }, [open, providerStatus, hasModalContent, defaultView, router, view]) - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (nextOpen && providerStatus && !hasModalContent) { - router.push(defaultView === 'login' ? '/login' : '/signup') - return - } - setOpen(nextOpen) - if (nextOpen) { - const initialView = - defaultView === 'signup' && providerStatus?.registrationDisabled ? 'login' : defaultView - setView(initialView) - captureClientEvent('auth_modal_opened', { view: initialView, source }) - } - }, - [defaultView, hasModalContent, providerStatus, router, source] - ) + function handleOpenChange(nextOpen: boolean) { + if (nextOpen && providerStatus && !hasModalContent) { + router.push(defaultView === 'login' ? '/login' : '/signup') + return + } + setOpen(nextOpen) + if (nextOpen) { + const initialView = + defaultView === 'signup' && providerStatus?.registrationDisabled ? 'login' : defaultView + setView(initialView) + captureClientEvent('auth_modal_opened', { view: initialView, source }) + } + } - const handleSocialLogin = useCallback(async (provider: 'github' | 'google') => { + async function handleSocialLogin(provider: 'github' | 'google') { setSocialLoading(provider) try { await client.signIn.social({ provider, callbackURL: '/workspace' }) @@ -114,17 +111,17 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal } finally { setSocialLoading(null) } - }, []) + } - const handleSSOLogin = useCallback(() => { + function handleSSOLogin() { setOpen(false) router.push('/sso') - }, [router]) + } - const handleEmailContinue = useCallback(() => { + function handleEmailContinue() { setOpen(false) router.push(view === 'login' ? '/login' : '/signup') - }, [router, view]) + } return ( diff --git a/apps/sim/app/(landing)/components/contact/contact-form.tsx b/apps/sim/app/(landing)/components/contact/contact-form.tsx index 11030ac760a..879f28133bc 100644 --- a/apps/sim/app/(landing)/components/contact/contact-form.tsx +++ b/apps/sim/app/(landing)/components/contact/contact-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { toError } from '@sim/utils/errors' import { useMutation } from '@tanstack/react-query' @@ -99,11 +99,7 @@ export function ContactForm() { const [isSubmitting, setIsSubmitting] = useState(false) const [website, setWebsite] = useState('') const [widgetReady, setWidgetReady] = useState(false) - const [turnstileSiteKey, setTurnstileSiteKey] = useState() - - useEffect(() => { - setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) - }, []) + const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) function updateField( field: TField, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0a58d8c2b34..bfa032ee2bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,6 +1,6 @@ 'use client' -import { createElement, useEffect, useMemo, useState } from 'react' +import { createElement, useMemo, useState } from 'react' import { useParams } from 'next/navigation' import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -403,15 +403,11 @@ interface OptionsDisplayProps { function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) { const disabled = !onSelect - const [expanded, setExpanded] = useState(!disabled) + const [collapsedByUser, setCollapsedByUser] = useState(false) + // When interactive (not disabled), always expanded. When disabled, the user can toggle. + const expanded = !disabled || !collapsedByUser const entries = Object.entries(data) - useEffect(() => { - if (!disabled) { - setExpanded(true) - } - }, [disabled]) - if (entries.length === 0) return null return ( @@ -419,7 +415,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) { {disabled ? ( + ) })}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index 616d33f99c5..0a047c89c3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -9,15 +9,16 @@ import { Button, ChevronDown, Code, + Copy as CopyIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, + Search as SearchIcon, Tooltip, } from '@/components/emcn' -import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' import type { TraceSpan } from '@/lib/logs/types' @@ -352,28 +353,28 @@ function InputOutputSection({ return JSON.stringify(data, null, 2) }, [data]) - const handleContextMenu = useCallback((e: React.MouseEvent) => { + function handleContextMenu(e: React.MouseEvent) { e.preventDefault() e.stopPropagation() setContextMenuPosition({ x: e.clientX, y: e.clientY }) setIsContextMenuOpen(true) - }, []) + } - const closeContextMenu = useCallback(() => { + function closeContextMenu() { setIsContextMenuOpen(false) - }, []) + } - const handleCopy = useCallback(() => { + function handleCopy() { navigator.clipboard.writeText(jsonString) setCopied(true) setTimeout(() => setCopied(false), 1500) closeContextMenu() - }, [jsonString, closeContextMenu]) + } - const handleSearch = useCallback(() => { + function handleSearch() { activateSearch() closeContextMenu() - }, [activateSearch, closeContextMenu]) + } return (
@@ -617,7 +618,7 @@ const TraceSpanNode = memo(function TraceSpanNode({ return kids.filter((c) => c.type?.toLowerCase() !== 'model') } return kids - }, [span, spanId]) + }, [span]) const hasChildren = displayChildren.length > 0 const isExpanded = isRootWorkflow || expandedNodes.has(spanId) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 78d925f204d..2fdca2d78e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -919,12 +919,16 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) const sorted = normalizeAndSort(traceSpans ?? []) let earliest = Number.POSITIVE_INFINITY let latest = 0 - for (const span of sorted) { - const s = parseTime(span.startTime) - const e = parseTime(span.endTime) - if (s < earliest) earliest = s - if (e > latest) latest = e + const walkTimeBounds = (spans: TraceSpan[]) => { + for (const span of spans) { + const s = parseTime(span.startTime) + const e = parseTime(span.endTime) + if (s < earliest) earliest = s + if (e > latest) latest = e + if (span.children?.length) walkTimeBounds(span.children) + } } + walkTimeBounds(sorted) const ids = collectAllIds(sorted) const count = ids.length const runStart = earliest !== Number.POSITIVE_INFINITY ? earliest : 0 diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 80fefa9fefe..f2c99711580 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -337,9 +337,15 @@ export const LogDetails = memo(function LogDetails({ const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab - useEffect(() => { - onActiveTabChange?.(resolvedTab) - }, [resolvedTab, onActiveTabChange]) + // When the trace tab disappears while it is active (e.g. data not yet loaded), resolvedTab + // falls back to 'overview'. Notify the parent inline so it sees the corrected value without + // waiting for an extra render cycle. The user-click path calls onActiveTabChange directly in + // onValueChange below. + const prevResolvedTabRef = useRef(resolvedTab) + if (prevResolvedTabRef.current !== resolvedTab) { + prevResolvedTabRef.current = resolvedTab + if (resolvedTab !== activeTab) onActiveTabChange?.(resolvedTab) + } const workflowOutput = useMemo(() => { const executionData = log?.executionData as @@ -463,7 +469,11 @@ export const LogDetails = memo(function LogDetails({ {/* Tabs */} setActiveTab(v as LogDetailsTab)} + onValueChange={(v) => { + const tab = v as LogDetailsTab + setActiveTab(tab) + onActiveTabChange?.(tab) + }} className='mt-4 flex min-h-0 flex-1 flex-col' > formatDate(log.createdAt), [log.createdAt]) + const formattedDate = formatDate(log.createdAt) const isMothershipJob = log.trigger === 'mothership' const isDeletedWorkflow = !isMothershipJob && !log.workflow?.id && !log.workflowId const workflowName = isMothershipJob @@ -288,28 +288,16 @@ export function LogsList({ const itemCount = hasNextPage ? logs.length + 1 : logs.length - const rowProps = useMemo( - () => ({ - logs, - selectedLogId, - onLogClick, - onLogHover, - onLogContextMenu, - selectedRowRef, - isFetchingNextPage, - loaderRef, - }), - [ - logs, - selectedLogId, - onLogClick, - onLogHover, - onLogContextMenu, - selectedRowRef, - isFetchingNextPage, - loaderRef, - ] - ) + const rowProps: RowProps = { + logs, + selectedLogId, + onLogClick, + onLogHover, + onLogContextMenu, + selectedRowRef, + isFetchingNextPage, + loaderRef, + } return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index cbaed69a90b..193a4cde105 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { Search, X } from 'lucide-react' import { useParams } from 'next/navigation' -import { Badge } from '@/components/emcn' +import { Badge, Button } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser' @@ -233,13 +233,14 @@ export function AutocompleteSearch({ {/* Clear All Button */} {(hasFilters || hasTextSearch) && ( - + )}
@@ -259,11 +260,12 @@ export function AutocompleteSearch({
{/* Show all results (no header) */} {suggestions[0]?.category === 'show-all' && ( - + )} {sections.map((section) => ( @@ -289,11 +291,12 @@ export function AutocompleteSearch({ const isHighlighted = index === highlightedIndex return ( -
- + ) })}
@@ -332,11 +335,12 @@ export function AutocompleteSearch({ )} {suggestions.map((suggestion, index) => ( -
- + ))}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index 501d208fb87..53494a181dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -9,13 +9,13 @@ import { Button, Combobox, type ComboboxOption, + DatePicker, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Loader, } from '@/components/emcn' -import { DatePicker } from '@/components/emcn/components/date-picker/date-picker' import { cn } from '@/lib/core/utils/cn' import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' @@ -397,28 +397,25 @@ export const LogsToolbar = memo(function LogsToolbar({ /** * Handles date range selection from DatePicker. */ - const handleDateRangeApply = useCallback( - (start: string, end: string) => { - dateRangeAppliedRef.current = true - setDateRange(start, end) - setDatePickerOpen(false) - captureEvent(posthogRef.current, 'logs_filter_applied', { - filter_type: 'time', - workspace_id: workspaceId, - }) - }, - [setDateRange, workspaceId] - ) + function handleDateRangeApply(start: string, end: string) { + dateRangeAppliedRef.current = true + setDateRange(start, end) + setDatePickerOpen(false) + captureEvent(posthogRef.current, 'logs_filter_applied', { + filter_type: 'time', + workspace_id: workspaceId, + }) + } /** * Handles date picker cancel. */ - const handleDatePickerCancel = useCallback(() => { + function handleDatePickerCancel() { if (timeRange === 'Custom range' && !startDate) { setTimeRange(previousTimeRange) } setDatePickerOpen(false) - }, [timeRange, startDate, previousTimeRange, setTimeRange]) + } const filtersActive = useMemo( () => @@ -433,10 +430,10 @@ export const LogsToolbar = memo(function LogsToolbar({ [timeRange, level, workflowIds, folderIds, triggers, searchQuery] ) - const handleClearFilters = useCallback(() => { + function handleClearFilters() { resetFilters() onSearchQueryChange('') - }, [resetFilters, onSearchQueryChange]) + } return (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx index 97b7f1d15b8..a6f8ed631a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx @@ -1135,12 +1135,15 @@ function TeamPlanModal({ open, onOpenChange, isAnnual, onConfirm }: TeamPlanModa const [selectedTier, setSelectedTier] = useState(PRO_TIER.credits) const [selectedSeats, setSelectedSeats] = useState(1) - useEffect(() => { + // Reset selections each time the modal opens. + const prevOpenRef = useRef(open) + if (prevOpenRef.current !== open) { + prevOpenRef.current = open if (open) { setSelectedTier(PRO_TIER.credits) setSelectedSeats(1) } - }, [open]) + } const tier = CREDIT_TIERS.find((t) => t.credits === selectedTier) ?? PRO_TIER const monthlyCostPerSeat = tier.dollars @@ -1293,9 +1296,12 @@ function ManagePlanModal({ const [isSwitching, setIsSwitching] = useState(false) const [error, setError] = useState(null) - useEffect(() => { + // Clear the error each time the modal opens. + const prevOpenRef = useRef(open) + if (prevOpenRef.current !== open) { + prevOpenRef.current = open if (open) setError(null) - }, [open]) + } const isOnMax = currentPlanCredits === MAX_TIER.credits || (isLegacyPlan && isTeamPlan) const currentTier = isOnMax ? MAX_TIER : PRO_TIER diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 346fc336a2e..7f28b5bc246 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -251,11 +251,11 @@ export function Table({ const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId }) const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) - const handleColumnOrderChange = useCallback((order: string[]) => { + function handleColumnOrderChange(order: string[]) { setColumnOrder(order) - }, []) + } - const handleColumnRename = useCallback((oldName: string, newName: string) => { + function handleColumnRename(oldName: string, newName: string) { let updatedWidths = columnWidthsRef.current if (oldName in updatedWidths) { const { [oldName]: width, ...rest } = updatedWidths @@ -268,13 +268,15 @@ export function Table({ columnWidths: updatedWidths, ...(updatedOrder ? { columnOrder: updatedOrder } : {}), }) - }, []) + } - const getColumnWidths = useCallback(() => columnWidthsRef.current, []) + function getColumnWidths() { + return columnWidthsRef.current + } - const handleColumnWidthsChange = useCallback((widths: Record) => { + function handleColumnWidthsChange(widths: Record) { setColumnWidths(widths) - }, []) + } const { pushUndo, undo, redo } = useTableUndo({ workspaceId, @@ -856,7 +858,7 @@ export function Table({ setDropTargetColumnName(null) }, []) - const handleScrollDragOver = useCallback((e: React.DragEvent) => { + function handleScrollDragOver(e: React.DragEvent) { if (!dragColumnNameRef.current) return e.preventDefault() e.dataTransfer.dropEffect = 'move' @@ -881,11 +883,11 @@ export function Table({ } left += w } - }, []) + } - const handleScrollDrop = useCallback((e: React.DragEvent) => { + function handleScrollDrop(e: React.DragEvent) { e.preventDefault() - }, []) + } useEffect(() => { if (!tableData?.metadata || metadataSeededRef.current) return @@ -3211,36 +3213,30 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ [onDragLeave] ) - const handleHeaderClick = useCallback( - (e: React.MouseEvent) => { - if (didDragRef.current) { - didDragRef.current = false - return - } - if (isRenaming) return - onColumnSelect(colIndex, e.shiftKey) - }, - [colIndex, isRenaming, onColumnSelect] - ) + function handleHeaderClick(e: React.MouseEvent) { + if (didDragRef.current) { + didDragRef.current = false + return + } + if (isRenaming) return + onColumnSelect(colIndex, e.shiftKey) + } - const handleChevronClick = useCallback((e: React.MouseEvent) => { + function handleChevronClick(e: React.MouseEvent) { e.stopPropagation() const rect = (e.currentTarget as HTMLElement).closest('th')?.getBoundingClientRect() if (rect) { setMenuPosition({ x: rect.left, y: rect.bottom }) } setMenuOpen(true) - }, []) + } - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - if (readOnly || isRenaming) return - e.preventDefault() - setMenuPosition({ x: e.clientX, y: e.clientY }) - setMenuOpen(true) - }, - [readOnly, isRenaming] - ) + function handleContextMenu(e: React.MouseEvent) { + if (readOnly || isRenaming) return + e.preventDefault() + setMenuPosition({ x: e.clientX, y: e.clientY }) + setMenuOpen(true) + } return (
(null) const importMutation = useImportCsvIntoTable() - const resetState = useCallback(() => { + function resetState() { setParsed(null) setParseError(null) setSubmitError(null) @@ -116,15 +116,18 @@ export function ImportCsvDialog({ setIsDragging(false) setParsing(false) if (fileInputRef.current) fileInputRef.current.value = '' - }, []) + } - useEffect(() => { - if (!open) resetState() - }, [open, resetState]) + function handleOpenChange(newOpen: boolean) { + if (!newOpen) resetState() + onOpenChange(newOpen) + } - useEffect(() => { + const prevTableIdRef = useRef(table.id) + if (prevTableIdRef.current !== table.id) { + prevTableIdRef.current = table.id resetState() - }, [table.id, resetState]) + } const columnOptions: ComboboxOption[] = useMemo(() => { const options: ComboboxOption[] = [{ label: 'Do not import', value: SKIP_VALUE }] @@ -137,82 +140,73 @@ export function ImportCsvDialog({ return options }, [table.schema.columns]) - const handleFileSelected = useCallback( - async (file: File) => { - const ext = file.name.split('.').pop()?.toLowerCase() - if (ext !== 'csv' && ext !== 'tsv') { - setParseError('Only CSV and TSV files are supported') - return - } - setParsing(true) - setParseError(null) - try { - const arrayBuffer = await file.arrayBuffer() - const delimiter = ext === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter) - const autoMapping = buildAutoMapping(headers, table.schema) - setParsed({ - file, - headers, - sampleRows: rows.slice(0, MAX_SAMPLE_ROWS), - totalRows: rows.length, - }) - setMapping(autoMapping) - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to parse CSV' - logger.error('CSV parse failed', err) - setParseError(message) - } finally { - setParsing(false) - } - }, - [table.schema] - ) + async function handleFileSelected(file: File) { + const ext = file.name.split('.').pop()?.toLowerCase() + if (ext !== 'csv' && ext !== 'tsv') { + setParseError('Only CSV and TSV files are supported') + return + } + setParsing(true) + setParseError(null) + try { + const arrayBuffer = await file.arrayBuffer() + const delimiter = ext === 'tsv' ? '\t' : ',' + const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter) + const autoMapping = buildAutoMapping(headers, table.schema) + setParsed({ + file, + headers, + sampleRows: rows.slice(0, MAX_SAMPLE_ROWS), + totalRows: rows.length, + }) + setMapping(autoMapping) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to parse CSV' + logger.error('CSV parse failed', err) + setParseError(message) + } finally { + setParsing(false) + } + } - const handleFileInputChange = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) void handleFileSelected(file) - }, - [handleFileSelected] - ) + function handleFileInputChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (file) void handleFileSelected(file) + } - const handleDragEnter = useCallback((e: React.DragEvent) => { + function handleDragEnter(e: React.DragEvent) { e.preventDefault() setIsDragging(true) - }, []) + } - const handleDragOver = useCallback((e: React.DragEvent) => { + function handleDragOver(e: React.DragEvent) { e.preventDefault() - }, []) + } - const handleDragLeave = useCallback((e: React.DragEvent) => { + function handleDragLeave(e: React.DragEvent) { e.preventDefault() setIsDragging(false) - }, []) - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - setIsDragging(false) - const file = e.dataTransfer.files?.[0] - if (file) void handleFileSelected(file) - }, - [handleFileSelected] - ) + } - const handleMappingChange = useCallback((header: string, value: string) => { + function handleDrop(e: React.DragEvent) { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) void handleFileSelected(file) + } + + function handleMappingChange(header: string, value: string) { setSubmitError(null) setMapping((prev) => ({ ...prev, [header]: value === SKIP_VALUE ? null : value, })) - }, []) + } - const handleModeChange = useCallback((value: string) => { + function handleModeChange(value: string) { setSubmitError(null) setMode(value as CsvImportMode) - }, []) + } const { missingRequired, duplicateTargets, mappedCount, skipCount } = useMemo(() => { const mappedTargets = new Map() @@ -263,7 +257,7 @@ export function ImportCsvDialog({ appendCapacityDeficit === 0 && replaceCapacityDeficit === 0 - const handleSubmit = useCallback(async () => { + async function handleSubmit() { if (!parsed || !canSubmit) return setSubmitError(null) try { @@ -292,18 +286,7 @@ export function ImportCsvDialog({ setSubmitError(summarizeImportError(message)) logger.error('CSV import into existing table failed', err) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - canSubmit, - mapping, - mode, - onImported, - onOpenChange, - parsed, - table.id, - table.name, - workspaceId, - ]) + } const hasWarning = missingRequired.length > 0 || @@ -312,7 +295,7 @@ export function ImportCsvDialog({ replaceCapacityDeficit > 0 return ( - + Import CSV into {table.name} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 37873145533..449d1900338 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { X } from 'lucide-react' import { useParams } from 'next/navigation' @@ -69,39 +69,34 @@ function SingleFileSelector({ isDeleting, }: SingleFileSelectorProps) { const displayLabel = `${truncateMiddle(file.name, 20, 12)} (${formatFileSize(file.size)})` - const [localInputValue, setLocalInputValue] = useState(displayLabel) + const [searchQuery, setSearchQuery] = useState('') const [isEditing, setIsEditing] = useState(false) - - // Sync display label when file changes - useEffect(() => { - if (!isEditing) { - setLocalInputValue(displayLabel) - } - }, [displayLabel, isEditing]) + // When not editing, always show the file's display label. When editing, show the user's query. + const comboboxValue = isEditing ? searchQuery : displayLabel return (
{ // Check if user selected an option const matched = options.find((opt) => opt.value === newValue || opt.label === newValue) if (matched) { setIsEditing(false) - setLocalInputValue(displayLabel) + setSearchQuery('') onInputChange(matched.value) return } // User is typing to search setIsEditing(true) - setLocalInputValue(newValue) + setSearchQuery(newValue) }} onOpenChange={(open) => { if (!open) { setIsEditing(false) - setLocalInputValue(displayLabel) + setSearchQuery('') } onOpenChange(open) }} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 57465d4edc8..e14ff170d5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -168,27 +168,24 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ }) { const dragGhostRef = useRef(null) - const handleDragStart = useCallback( - (e: React.DragEvent) => { - e.dataTransfer.effectAllowed = 'copyMove' - e.dataTransfer.setData( - SIM_RESOURCES_DRAG_TYPE, - JSON.stringify([{ type: 'task', id: task.id, title: task.name }]) - ) - const ghost = createSidebarDragGhost(task.name, { kind: 'task' }) - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost - }, - [task.id, task.name] - ) + function handleDragStart(e: React.DragEvent) { + e.dataTransfer.effectAllowed = 'copyMove' + e.dataTransfer.setData( + SIM_RESOURCES_DRAG_TYPE, + JSON.stringify([{ type: 'task', id: task.id, title: task.name }]) + ) + const ghost = createSidebarDragGhost(task.name, { kind: 'task' }) + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + } - const handleDragEnd = useCallback(() => { + function handleDragEnd() { if (dragGhostRef.current) { dragGhostRef.current.remove() dragGhostRef.current = null } - }, []) + } return ( diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index cd7bdf6b369..0bc9fcfc439 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -124,7 +124,7 @@ export function useCreatePermissionGroup() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId), }) @@ -157,7 +157,7 @@ export function useUpdatePermissionGroup() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId), }) @@ -191,7 +191,7 @@ export function useDeletePermissionGroup() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId), }) @@ -221,7 +221,7 @@ export function useRemovePermissionGroupMember() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.members(variables.workspaceId, variables.permissionGroupId), }) @@ -259,7 +259,7 @@ export function useBulkAddPermissionGroupMembers() { } return response.json() as Promise<{ added: number; moved: number }> }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.members(variables.workspaceId, variables.permissionGroupId), }) diff --git a/apps/sim/hooks/queries/a2a/tasks.ts b/apps/sim/hooks/queries/a2a/tasks.ts index abb9bbb3acb..b32aace330a 100644 --- a/apps/sim/hooks/queries/a2a/tasks.ts +++ b/apps/sim/hooks/queries/a2a/tasks.ts @@ -132,10 +132,12 @@ export function useSendA2ATask() { return useMutation({ mutationFn: sendA2ATask, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ - queryKey: a2aTaskKeys.detail(variables.agentUrl, data.taskId), - }) + onSettled: (data, _error, variables) => { + if (data) { + queryClient.invalidateQueries({ + queryKey: a2aTaskKeys.detail(variables.agentUrl, data.taskId), + }) + } }, }) } @@ -255,7 +257,7 @@ export function useCancelA2ATask() { return useMutation({ mutationFn: cancelA2ATask, - onSuccess: (data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: a2aTaskKeys.detail(variables.agentUrl, variables.taskId), }) diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 1afe149113f..8b4e65e1de0 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -330,7 +330,7 @@ export function useCreateChat() { logger.info('Chat deployed successfully:', result.chatUrl) return { chatUrl: result.chatUrl, chatId: result.chatId } }, - onSuccess: (_, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(variables.workflowId), }) @@ -385,7 +385,7 @@ export function useUpdateChat() { logger.info('Chat updated successfully:', result.chatUrl) return { chatUrl: result.chatUrl, chatId } }, - onSuccess: (_, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(variables.workflowId), }) @@ -419,7 +419,7 @@ export function useDeleteChat() { logger.info('Chat deleted successfully') }, - onSuccess: (_, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(variables.workflowId), }) diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 6e7498f13b6..d80ce3a2106 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -459,19 +459,20 @@ export function useUpdateDeploymentVersion() { return response.json() }, - onSuccess: (_, variables) => { - logger.info('Deployment version updated', { - workflowId: variables.workflowId, - version: variables.version, - }) + onSettled: (_data, error, variables) => { + if (!error) { + logger.info('Deployment version updated', { + workflowId: variables.workflowId, + version: variables.version, + }) + } else { + logger.error('Failed to update deployment version', { error }) + } queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(variables.workflowId), }) }, - onError: (error) => { - logger.error('Failed to update deployment version', { error }) - }, }) } @@ -696,18 +697,19 @@ export function useUpdatePublicApi() { return response.json() }, - onSuccess: (_, variables) => { - logger.info('Public API setting updated', { - workflowId: variables.workflowId, - isPublicApi: variables.isPublicApi, - }) + onSettled: (_data, error, variables) => { + if (!error) { + logger.info('Public API setting updated', { + workflowId: variables.workflowId, + isPublicApi: variables.isPublicApi, + }) + } else { + logger.error('Failed to update public API setting', { error }) + } queryClient.invalidateQueries({ queryKey: deploymentKeys.info(variables.workflowId), }) }, - onError: (error) => { - logger.error('Failed to update public API setting', { error }) - }, }) } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 7561ae9713a..ed530775a3c 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -196,7 +196,7 @@ export function useResendWorkspaceInvitation() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: invitationKeys.list(variables.workspaceId), }) @@ -232,7 +232,7 @@ export function useRemoveWorkspaceMember() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId), }) @@ -278,7 +278,7 @@ export function useLeaveWorkspace() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId), }) @@ -313,7 +313,7 @@ export function useUpdateWorkspacePermissions() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId), }) diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index c8a528cccc7..ca85a3f6ff7 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -166,7 +166,7 @@ export function useCreateConnector() { return useMutation({ mutationFn: createConnector, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -213,7 +213,7 @@ export function useUpdateConnector() { return useMutation({ mutationFn: updateConnector, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId), }) @@ -253,7 +253,7 @@ export function useDeleteConnector() { return useMutation({ mutationFn: deleteConnector, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -282,7 +282,7 @@ export function useTriggerSync() { return useMutation({ mutationFn: triggerSync, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -393,7 +393,7 @@ export function useExcludeConnectorDocument() { return useMutation({ mutationFn: excludeConnectorDocuments, - onSuccess: (_, { knowledgeBaseId, connectorId }) => { + onSettled: (_data, _error, { knowledgeBaseId, connectorId }) => { queryClient.invalidateQueries({ queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId), }) @@ -432,7 +432,7 @@ export function useRestoreConnectorDocument() { return useMutation({ mutationFn: restoreConnectorDocuments, - onSuccess: (_, { knowledgeBaseId, connectorId }) => { + onSettled: (_data, _error, { knowledgeBaseId, connectorId }) => { queryClient.invalidateQueries({ queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId), }) diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index e1d3343a57d..a93e8b2204a 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -442,7 +442,7 @@ export function useUpdateChunk() { return useMutation({ mutationFn: updateChunk, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -485,7 +485,7 @@ export function useDeleteChunk() { return useMutation({ mutationFn: deleteChunk, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -533,7 +533,7 @@ export function useCreateChunk() { return useMutation({ mutationFn: createChunk, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -584,7 +584,7 @@ export function useUpdateDocument() { return useMutation({ mutationFn: updateDocument, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -624,7 +624,7 @@ export function useDeleteDocument() { return useMutation({ mutationFn: deleteDocument, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -685,7 +685,7 @@ export function useBulkDocumentOperation() { return useMutation({ mutationFn: bulkDocumentOperation, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -733,7 +733,7 @@ export function useCreateKnowledgeBase(workspaceId?: string) { return useMutation({ mutationFn: createKnowledgeBase, - onSuccess: () => { + onSettled: () => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists(), }) @@ -781,7 +781,7 @@ export function useUpdateKnowledgeBase(workspaceId?: string) { onError: (error) => { toast.error(error.message, { duration: 5000 }) }, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists(), }) @@ -819,7 +819,7 @@ export function useDeleteKnowledgeBase(workspaceId?: string) { return useMutation({ mutationFn: deleteKnowledgeBase, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists(), }) @@ -875,7 +875,7 @@ export function useBulkChunkOperation() { return useMutation({ mutationFn: bulkChunkOperation, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -921,7 +921,7 @@ export function useUpdateDocumentTags() { return useMutation({ mutationFn: updateDocumentTags, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -1054,7 +1054,7 @@ export function useCreateTagDefinition() { return useMutation({ mutationFn: createTagDefinition, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId), }) @@ -1092,7 +1092,7 @@ export function useDeleteTagDefinition() { return useMutation({ mutationFn: deleteTagDefinition, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId), }) @@ -1195,7 +1195,7 @@ export function useSaveDocumentTagDefinitions() { return useMutation({ mutationFn: saveDocumentTagDefinitions, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentTagDefinitions(knowledgeBaseId, documentId), }) @@ -1254,7 +1254,7 @@ export function useDeleteDocumentTagDefinitions() { return useMutation({ mutationFn: deleteDocumentTagDefinitions, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentTagDefinitions(knowledgeBaseId, documentId), }) diff --git a/apps/sim/hooks/queries/mothership-admin.ts b/apps/sim/hooks/queries/mothership-admin.ts index 592daea4d86..5a9b3e10aa7 100644 --- a/apps/sim/hooks/queries/mothership-admin.ts +++ b/apps/sim/hooks/queries/mothership-admin.ts @@ -65,6 +65,7 @@ export function useMothershipRequests( ...(userId ? { userId } : {}), }), enabled: !!start && !!end, + staleTime: 60 * 1000, placeholderData: keepPreviousData, }) } @@ -74,6 +75,7 @@ export function useMothershipUserBreakdown(environment: MothershipEnv, start: st queryKey: mothershipKeys.userBreakdown(environment, start, end), queryFn: () => mothershipPost('user-breakdown', environment, { start, end }), enabled: !!start && !!end, + staleTime: 60 * 1000, placeholderData: keepPreviousData, }) } @@ -82,6 +84,7 @@ export function useMothershipLicenses(environment: MothershipEnv) { return useQuery({ queryKey: mothershipKeys.licenses(environment), queryFn: () => mothershipGet('licenses', environment), + staleTime: 60 * 1000, }) } @@ -98,6 +101,7 @@ export function useMothershipLicenseDetails( ...(name ? { name } : {}), }), enabled: !!(id || name), + staleTime: 60 * 1000, }) } @@ -118,6 +122,7 @@ export function useMothershipEnterpriseStats( queryKey: mothershipKeys.enterpriseStats(environment, customerType, start, end), queryFn: () => mothershipPost('enterprise-stats', environment, { customerType, start, end }), enabled: !!customerType && !!start && !!end, + staleTime: 60 * 1000, placeholderData: keepPreviousData, }) } @@ -127,5 +132,6 @@ export function useMothershipTrace(environment: MothershipEnv, requestId: string queryKey: mothershipKeys.trace(environment, requestId), queryFn: () => mothershipGet('traces', environment, { requestId }), enabled: !!requestId, + staleTime: 60 * 1000, }) } diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index b55bf9ffe50..aacd1e28297 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -396,7 +396,7 @@ export function useRemoveMember() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) }) @@ -433,7 +433,7 @@ export function useUpdateOrganizationMemberRole() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) }, @@ -468,7 +468,7 @@ export function useTransferOwnership() { details?: Record }> }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) @@ -503,7 +503,7 @@ export function useUpdateInvitation() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) }, @@ -534,7 +534,7 @@ export function useCancelInvitation() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) @@ -569,7 +569,7 @@ export function useResendInvitation() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) }, @@ -602,7 +602,7 @@ export function useUpdateSeats() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) @@ -640,7 +640,7 @@ export function useUpdateOrganization() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) }, @@ -682,7 +682,7 @@ export function useCreateOrganization() { return data }, - onSuccess: () => { + onSettled: () => { queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) }, diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 6fcc3e16f92..0a47479a242 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -200,7 +200,7 @@ export function useDeleteWorkspace() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) }) }, @@ -237,7 +237,7 @@ export function useUpdateWorkspace() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) }) }, @@ -408,7 +408,7 @@ export function useUpdateWorkspaceSettings() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.settings(variables.workspaceId), }) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 55d98ca5588..896267499de 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -309,7 +309,13 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const row = await insertRow( - { tableId: args.tableId, data: args.data, workspaceId, userId: context.userId }, + { + tableId: args.tableId, + data: args.data, + workspaceId, + userId: context.userId, + position: args.position as number | undefined, + }, table, requestId ) @@ -340,7 +346,13 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const rows = await batchInsertRows( - { tableId: args.tableId, rows: args.rows, workspaceId, userId: context.userId }, + { + tableId: args.tableId, + rows: args.rows, + workspaceId, + userId: context.userId, + positions: args.positions as number[] | undefined, + }, table, requestId ) From 1cfad550cd1c90588c610a1c9012cdfef6b4e345 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 17:04:29 -0700 Subject: [PATCH 27/28] fix(logs): reset detail panel tab to overview on log switch --- .../logs/components/log-details/log-details.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index f2c99711580..ed03ba066a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -299,7 +299,15 @@ export const LogDetails = memo(function LogDetails({ }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) const [activeTab, setActiveTab] = useState('overview') + const [prevLogId, setPrevLogId] = useState(log?.id) const [copiedRunId, setCopiedRunId] = useState(false) + + // Reset tab to overview when the selected log changes so the user never gets + // trapped on the Trace tab (which would suppress arrow-key log navigation). + if (prevLogId !== log?.id) { + setPrevLogId(log?.id) + setActiveTab('overview') + } const copiedRunIdTimerRef = useRef(null) const scrollAreaRef = useRef(null) From 173ff667ce7029be2ccf3eed7a371b79e357e335 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 28 Apr 2026 17:05:43 -0700 Subject: [PATCH 28/28] chore(logs): remove extraneous comments --- .../logs/components/log-details/log-details.tsx | 6 ------ apps/sim/app/workspace/[workspaceId]/logs/logs.tsx | 1 - 2 files changed, 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index ed03ba066a3..2be31bc5bcc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -302,8 +302,6 @@ export const LogDetails = memo(function LogDetails({ const [prevLogId, setPrevLogId] = useState(log?.id) const [copiedRunId, setCopiedRunId] = useState(false) - // Reset tab to overview when the selected log changes so the user never gets - // trapped on the Trace tab (which would suppress arrow-key log navigation). if (prevLogId !== log?.id) { setPrevLogId(log?.id) setActiveTab('overview') @@ -345,10 +343,6 @@ export const LogDetails = memo(function LogDetails({ const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab - // When the trace tab disappears while it is active (e.g. data not yet loaded), resolvedTab - // falls back to 'overview'. Notify the parent inline so it sees the corrected value without - // waiting for an extra render cycle. The user-click path calls onActiveTabChange directly in - // onValueChange below. const prevResolvedTabRef = useRef(resolvedTab) if (prevResolvedTabRef.current !== resolvedTab) { prevResolvedTabRef.current = resolvedTab diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 87449ebc701..f37e7b444ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -706,7 +706,6 @@ export default function Logs() { const handleKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return - // When the trace tab is active, arrow keys belong to TraceView's span navigator. if (activeLogTabRef.current === 'trace') return const currentLogs = logsRef.current const currentIndex = selectedLogIndexRef.current