diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx
index 5a073a04b..c7ae2bf61 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx
@@ -6,11 +6,11 @@ import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
+import { formatDuration } from '@/lib/core/utils/formatting'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate,
- formatDuration,
getDisplayStatus,
LOG_COLUMNS,
StatusBadge,
@@ -113,7 +113,7 @@ const LogRow = memo(
- {formatDuration(log.duration) || '—'}
+ {formatDuration(log.duration, { precision: 2 }) || '—'}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
index d21561b97..570262d10 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
@@ -1,6 +1,7 @@
import React from 'react'
import { format } from 'date-fns'
import { Badge } from '@/components/emcn'
+import { formatDuration } from '@/lib/core/utils/formatting'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
@@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
}
}
-/**
- * Format duration for display in logs UI
- * If duration is under 1 second, displays as milliseconds (e.g., "500ms")
- * If duration is 1 second or more, displays as seconds (e.g., "1.23s")
- * @param duration - Duration string (e.g., "500ms") or null
- * @returns Formatted duration string or null
- */
-export function formatDuration(duration: string | null): string | null {
- if (!duration) return null
-
- // Extract numeric value from duration string (e.g., "500ms" -> 500)
- const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
-
- if (!Number.isFinite(ms)) return duration
-
- if (ms < 1000) {
- return `${ms}ms`
- }
-
- // Convert to seconds with up to 2 decimal places
- const seconds = ms / 1000
- return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
-}
-
/**
* Format latency value for display in dashboard UI
- * If latency is under 1 second, displays as milliseconds (e.g., "500ms")
- * If latency is 1 second or more, displays as seconds (e.g., "1.23s")
* @param ms - Latency in milliseconds (number)
* @returns Formatted latency string
*/
export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
-
- if (ms < 1000) {
- return `${Math.round(ms)}ms`
- }
-
- // Convert to seconds with up to 2 decimal places
- const seconds = ms / 1000
- return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
+ return formatDuration(ms, { precision: 2 }) ?? '—'
}
export const formatDate = (dateString: string) => {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx
index de632ca5f..3c95d83d4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx
@@ -3,6 +3,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
+import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
@@ -241,15 +242,11 @@ export function ThinkingBlock({
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
- /** Formats duration in milliseconds to seconds (minimum 1s) */
- const formatDuration = (ms: number) => {
- const seconds = Math.max(1, Math.round(ms / 1000))
- return `${seconds}s`
- }
-
const hasContent = cleanContent.length > 0
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
- const durationText = `${label} for ${formatDuration(duration)}`
+ // Round to nearest second (minimum 1s) to match original behavior
+ const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
+ const durationText = `${label} for ${formatDuration(roundedMs)}`
const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
index d22542375..f6ee0679a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx
@@ -15,6 +15,7 @@ import {
hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config'
+import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
@@ -848,13 +849,10 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
(allParsed.options && Object.keys(allParsed.options).length > 0)
)
- const formatDuration = (ms: number) => {
- const seconds = Math.max(1, Math.round(ms / 1000))
- return `${seconds}s`
- }
-
const outerLabel = getSubagentCompletionLabel(toolCall.name)
- const durationText = `${outerLabel} for ${formatDuration(duration)}`
+ // Round to nearest second (minimum 1s) to match original behavior
+ const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
+ const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
const renderCollapsibleContent = () => (
<>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
index abe60c6a4..540f97bba 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
@@ -24,6 +24,7 @@ import {
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { formatDuration } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -43,7 +44,6 @@ import {
type EntryNode,
type ExecutionGroup,
flattenBlockEntriesOnly,
- formatDuration,
getBlockColor,
getBlockIcon,
groupEntriesByExecution,
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
index 6dbc15770..18b8cfef6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts
@@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string {
return '#6b7280'
}
-/**
- * Formats duration from milliseconds to readable format
- */
-export function formatDuration(ms?: number): string {
- if (ms === undefined || ms === null) return '-'
- if (ms < 1000) {
- return `${Math.round(ms)}ms`
- }
- return `${(ms / 1000).toFixed(2)}s`
-}
-
/**
* Determines if a keyboard event originated from a text-editable element
*/
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx
index cbdac251f..3003d4acd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx
@@ -30,6 +30,7 @@ import {
Textarea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
+import { formatDuration } from '@/lib/core/utils/formatting'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
@@ -575,7 +576,9 @@ export function TrainingModal() {
Duration:{' '}
{dataset.metadata?.duration
- ? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
+ ? formatDuration(dataset.metadata.duration, {
+ precision: 1,
+ })
: 'N/A'}
diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts
index d5dbf3a92..0d9a8254b 100644
--- a/apps/sim/background/workspace-notification-delivery.ts
+++ b/apps/sim/background/workspace-notification-delivery.ts
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
+import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -227,12 +228,6 @@ async function deliverWebhook(
}
}
-function formatDuration(ms: number): string {
- if (ms < 1000) return `${ms}ms`
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
- return `${(ms / 60000).toFixed(1)}m`
-}
-
function formatCost(cost?: Record): string {
if (!cost?.total) return 'N/A'
const total = cost.total as number
@@ -302,7 +297,7 @@ async function deliverEmail(
workflowName: payload.data.workflowName || 'Unknown Workflow',
status: payload.data.status,
trigger: payload.data.trigger,
- duration: formatDuration(payload.data.totalDurationMs),
+ duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
cost: formatCost(payload.data.cost),
logUrl,
alertReason,
@@ -315,7 +310,7 @@ async function deliverEmail(
to: subscription.emailRecipients,
subject,
html,
- text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
+ text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
emailType: 'notifications',
})
@@ -373,7 +368,10 @@ async function deliverSlack(
fields: [
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
- { type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
+ {
+ type: 'mrkdwn',
+ text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
+ },
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
],
},
diff --git a/apps/sim/components/ui/tool-call.tsx b/apps/sim/components/ui/tool-call.tsx
index b6d76ca7e..0d7d2ece2 100644
--- a/apps/sim/components/ui/tool-call.tsx
+++ b/apps/sim/components/ui/tool-call.tsx
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
import { cn } from '@/lib/core/utils/cn'
+import { formatDuration } from '@/lib/core/utils/formatting'
interface ToolCallProps {
toolCall: ToolCallState
@@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
const isError = toolCall.state === 'error'
const isAborted = toolCall.state === 'aborted'
- const formatDuration = (duration?: number) => {
- if (!duration) return ''
- return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
- }
-
return (
- {formatDuration(toolCall.duration)}
+ {toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
)}
diff --git a/apps/sim/lib/core/utils/formatting.ts b/apps/sim/lib/core/utils/formatting.ts
index abd0f8805..a7051df03 100644
--- a/apps/sim/lib/core/utils/formatting.ts
+++ b/apps/sim/lib/core/utils/formatting.ts
@@ -153,22 +153,50 @@ export function formatCompactTimestamp(iso: string): string {
}
/**
- * Format a duration in milliseconds to a human-readable format
- * @param durationMs - The duration in milliseconds
+ * Format a duration to a human-readable format
+ * @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
* @param options - Optional formatting options
- * @param options.precision - Number of decimal places for seconds (default: 0)
- * @returns A formatted duration string
+ * @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
+ * @returns A formatted duration string, or null if input is null/undefined
*/
-export function formatDuration(durationMs: number, options?: { precision?: number }): string {
- const precision = options?.precision ?? 0
-
- if (durationMs < 1000) {
- return `${durationMs}ms`
+export function formatDuration(
+ duration: number | string | undefined | null,
+ options?: { precision?: number }
+): string | null {
+ if (duration === undefined || duration === null) {
+ return null
}
- const seconds = durationMs / 1000
+ // Parse string durations (e.g., "500ms", "0.44ms", "1234")
+ let ms: number
+ if (typeof duration === 'string') {
+ ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
+ if (!Number.isFinite(ms)) {
+ return duration
+ }
+ } else {
+ ms = duration
+ }
+
+ const precision = options?.precision ?? 0
+
+ if (ms < 1) {
+ // Sub-millisecond: show with 2 decimal places
+ return `${ms.toFixed(2)}ms`
+ }
+
+ if (ms < 1000) {
+ // Milliseconds: round to integer
+ return `${Math.round(ms)}ms`
+ }
+
+ const seconds = ms / 1000
if (seconds < 60) {
- return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
+ if (precision > 0) {
+ // Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
+ return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
+ }
+ return `${Math.floor(seconds)}s`
}
const minutes = Math.floor(seconds / 60)