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 bde89f51df8..4990fd6609e 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 @@ -29,11 +29,13 @@ import { ChevronUp, Clipboard, Search, + SquareArrowUpRight, Workflow, Wrench, X, } from '@sim/emcn/icons' import { formatDuration } from '@sim/utils/formatting' +import Link from 'next/link' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { createPortal } from 'react-dom' @@ -59,8 +61,10 @@ import { DELETED_WORKFLOW_LABEL, formatDate, getDisplayStatus, + resolveLogWorkflowId, StatusBadge, TriggerBadge, + workflowEditorPath, } from '@/app/workspace/[workspaceId]/logs/utils' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -317,6 +321,19 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP const isWorkflowExecutionLog = (log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans + /** + * The workflow this run belongs to, when it is still reachable. Null for Sim + * agent jobs and deleted workflows, which render their label as static text. + */ + const openableWorkflowId = resolveLogWorkflowId(log) + + const workflowLabel = + log.trigger === 'mothership' + ? log.jobTitle || 'Untitled Job' + : openableWorkflowId + ? log.workflow?.name || 'Unknown' + : DELETED_WORKFLOW_LABEL + const hasCostInfo = !!(isWorkflowExecutionLog && log.cost) const showWorkflowState = isWorkflowExecutionLog && @@ -465,15 +482,31 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {log.trigger === 'mothership' ? 'Job' : 'Workflow'} -
- - - {log.trigger === 'mothership' - ? log.jobTitle || 'Untitled Job' - : log.workflow?.name || - (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} - -
+ {openableWorkflowId ? ( + + + + + + + {workflowLabel} + + (opens in a new tab) + + ) : ( +
+ + + {workflowLabel} + +
+ )} 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 0b2f7c11a2b..d0c8940bd8d 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 @@ -16,6 +16,7 @@ import { X, } from '@sim/emcn' import type { WorkflowLogSummary } from '@/lib/api/contracts/logs' +import { resolveLogWorkflowId } from '@/app/workspace/[workspaceId]/logs/utils' interface LogRowContextMenuProps { isOpen: boolean @@ -58,6 +59,12 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ }: LogRowContextMenuProps) { const hasExecutionId = Boolean(log?.executionId) const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId) + /** + * "Open Workflow" needs a navigable target, which is stricter than + * `hasWorkflow`: Sim agent jobs have no workflow of their own. Cancel/retry + * keep using `hasWorkflow` so their gating is unchanged. + */ + const hasOpenableWorkflow = Boolean(log && resolveLogWorkflowId(log)) const isCancellable = (log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow const isRetryable = log?.status === 'failed' && hasWorkflow && log?.trigger !== 'mothership' @@ -112,7 +119,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ - + Open Workflow diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 7bf6142e139..5d812699e7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -92,9 +92,11 @@ import { getDisplayStatus, type LogStatus, parseDuration, + resolveLogWorkflowId, STATUS_CONFIG, StatusBadge, TriggerBadge, + workflowEditorPath, } from './utils' const LOGS_PER_PAGE = 50 as const @@ -524,9 +526,9 @@ export default function Logs() { }, [contextMenuLog, workspaceId]) const handleOpenWorkflow = useCallback(() => { - const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId + const wfId = contextMenuLog ? resolveLogWorkflowId(contextMenuLog) : null if (wfId) { - window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank') + window.open(workflowEditorPath(workspaceId, wfId), '_blank') } }, [contextMenuLog, workspaceId]) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts new file mode 100644 index 00000000000..4d4cff37ec7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { resolveLogWorkflowId, workflowEditorPath } from './utils' + +describe('resolveLogWorkflowId', () => { + it('returns the nested workflow id when present', () => { + expect( + resolveLogWorkflowId({ trigger: 'manual', workflowId: 'wf-1', workflow: { id: 'wf-1' } }) + ).toBe('wf-1') + }) + + it('falls back to workflowId when the workflow object is absent', () => { + expect(resolveLogWorkflowId({ trigger: 'api', workflowId: 'wf-2', workflow: null })).toBe( + 'wf-2' + ) + }) + + it('prefers the nested workflow id over workflowId when both are set', () => { + expect( + resolveLogWorkflowId({ trigger: 'manual', workflowId: 'stale', workflow: { id: 'fresh' } }) + ).toBe('fresh') + }) + + it('returns null for Sim agent jobs even when a workflow id exists', () => { + expect( + resolveLogWorkflowId({ + trigger: 'mothership', + workflowId: 'wf-3', + workflow: { id: 'wf-3' }, + }) + ).toBeNull() + }) + + it('returns null for a deleted workflow (both id fields empty)', () => { + expect(resolveLogWorkflowId({ trigger: 'manual', workflowId: null, workflow: null })).toBeNull() + }) + + it('returns null when ids are present but empty strings', () => { + expect( + resolveLogWorkflowId({ trigger: 'manual', workflowId: '', workflow: { id: '' } }) + ).toBeNull() + }) + + it('treats a missing trigger as a normal workflow run', () => { + expect(resolveLogWorkflowId({ workflowId: 'wf-4' })).toBe('wf-4') + }) +}) + +describe('workflowEditorPath', () => { + it('builds the workspace-scoped editor path', () => { + expect(workflowEditorPath('ws-1', 'wf-1')).toBe('/workspace/ws-1/w/wf-1') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index fbffba81702..05820985af3 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -17,6 +17,29 @@ export const LOG_COLUMNS = { export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow' +/** + * Resolves the workflow a log row points at, or null when there is nowhere to + * navigate. Sim agent jobs have no workflow of their own, and a deleted + * workflow leaves both id fields empty. + * + * Single source of truth for "is this log's workflow reachable" — the list row, + * its context menu, and the details panel must agree, or a row can render as + * "Deleted Workflow" while still linking somewhere. + */ +export function resolveLogWorkflowId(log: { + trigger?: string | null + workflowId?: string | null + workflow?: { id?: string } | null +}): string | null { + if (log.trigger === 'mothership') return null + return log.workflow?.id || log.workflowId || null +} + +/** Path to a workflow in the editor. */ +export function workflowEditorPath(workspaceId: string, workflowId: string): string { + return `/workspace/${workspaceId}/w/${workflowId}` +} + export type LogStatus = | 'error' | 'pending'