Skip to content

Commit 5718def

Browse files
authored
feat(logs): open the workflow from the log details panel (#6275)
* refactor(logs): centralize workflow-id resolution and editor path The logs list, its context menu, and the details panel each resolved a log's workflow id with their own copy of `workflow?.id || workflowId`, and the list disagreed with the details panel on what counts as a deleted workflow. Extract `resolveLogWorkflowId` and `workflowEditorPath` so the three surfaces cannot drift. `resolveLogWorkflowId` also returns null for Sim agent jobs, which have no workflow of their own. Only the context menu's "Open Workflow" item adopts that stricter predicate; cancel and retry keep using the previous `hasWorkflow` check so their gating is unchanged. * feat(logs): open the workflow from the log details panel The workflow name in a log's details panel was static text, so the only way to reach the workflow was the row's right-click context menu. Make the label a link to the workflow editor, opening in a new tab so the log list keeps its filters, scroll position, and open panel. On hover or keyboard focus the leading workflow icon morphs into SquareArrowUpRight, reusing the grid-stacked cross-fade already used by the resource header breadcrumb. Sim agent jobs and deleted workflows have no reachable workflow and stay static text. Adds a `group-hover-hover` variant so the morph is gated on a real hover-capable pointer, matching the existing `hover-hover` variant and keeping touch devices out of a half-applied hover state. * refactor(logs): use the existing group-hover variant for the workflow link Drops the group-hover-hover Tailwind variant this branch added and moves the details-panel workflow link to plain group-hover:, matching the variant already used throughout the app. tailwind.config.ts is untouched by the branch again; all colors, radii, and the focus ring come from existing design tokens.
1 parent 020ec68 commit 5718def

5 files changed

Lines changed: 132 additions & 12 deletions

File tree

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ import {
2929
ChevronUp,
3030
Clipboard,
3131
Search,
32+
SquareArrowUpRight,
3233
Workflow,
3334
Wrench,
3435
X,
3536
} from '@sim/emcn/icons'
3637
import { formatDuration } from '@sim/utils/formatting'
38+
import Link from 'next/link'
3739
import { useParams, useRouter } from 'next/navigation'
3840
import { useQueryState } from 'nuqs'
3941
import { createPortal } from 'react-dom'
@@ -59,8 +61,10 @@ import {
5961
DELETED_WORKFLOW_LABEL,
6062
formatDate,
6163
getDisplayStatus,
64+
resolveLogWorkflowId,
6265
StatusBadge,
6366
TriggerBadge,
67+
workflowEditorPath,
6468
} from '@/app/workspace/[workspaceId]/logs/utils'
6569
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
6670
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -317,6 +321,19 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
317321
const isWorkflowExecutionLog =
318322
(log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans
319323

324+
/**
325+
* The workflow this run belongs to, when it is still reachable. Null for Sim
326+
* agent jobs and deleted workflows, which render their label as static text.
327+
*/
328+
const openableWorkflowId = resolveLogWorkflowId(log)
329+
330+
const workflowLabel =
331+
log.trigger === 'mothership'
332+
? log.jobTitle || 'Untitled Job'
333+
: openableWorkflowId
334+
? log.workflow?.name || 'Unknown'
335+
: DELETED_WORKFLOW_LABEL
336+
320337
const hasCostInfo = !!(isWorkflowExecutionLog && log.cost)
321338
const showWorkflowState =
322339
isWorkflowExecutionLog &&
@@ -465,15 +482,31 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
465482
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
466483
{log.trigger === 'mothership' ? 'Job' : 'Workflow'}
467484
</span>
468-
<div className='flex min-w-0 items-center gap-1.5'>
469-
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
470-
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
471-
{log.trigger === 'mothership'
472-
? log.jobTitle || 'Untitled Job'
473-
: log.workflow?.name ||
474-
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
475-
</span>
476-
</div>
485+
{openableWorkflowId ? (
486+
<Link
487+
href={workflowEditorPath(workspaceId, openableWorkflowId)}
488+
target='_blank'
489+
rel='noopener noreferrer'
490+
prefetch={false}
491+
className='-mx-1.5 -my-0.5 group flex w-fit min-w-0 max-w-[calc(100%+0.75rem)] items-center gap-1.5 rounded-[5px] px-1.5 py-0.5 transition-colors hover-hover:bg-[var(--surface-active)] focus-visible:bg-[var(--surface-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]'
492+
>
493+
<span className='inline-grid size-[14px] shrink-0 place-items-center'>
494+
<Workflow className='col-start-1 row-start-1 size-[14px] text-[var(--text-icon)] opacity-100 blur-0 transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
495+
<SquareArrowUpRight className='col-start-1 row-start-1 size-[14px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-0 motion-reduce:transition-none' />
496+
</span>
497+
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm transition-colors group-hover:text-[var(--text-primary)] group-focus-visible:text-[var(--text-primary)]'>
498+
{workflowLabel}
499+
</span>
500+
<span className='sr-only'>(opens in a new tab)</span>
501+
</Link>
502+
) : (
503+
<div className='flex min-w-0 items-center gap-1.5'>
504+
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
505+
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
506+
{workflowLabel}
507+
</span>
508+
</div>
509+
)}
477510
</div>
478511
</div>
479512

apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
X,
1717
} from '@sim/emcn'
1818
import type { WorkflowLogSummary } from '@/lib/api/contracts/logs'
19+
import { resolveLogWorkflowId } from '@/app/workspace/[workspaceId]/logs/utils'
1920

2021
interface LogRowContextMenuProps {
2122
isOpen: boolean
@@ -58,6 +59,12 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
5859
}: LogRowContextMenuProps) {
5960
const hasExecutionId = Boolean(log?.executionId)
6061
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
62+
/**
63+
* "Open Workflow" needs a navigable target, which is stricter than
64+
* `hasWorkflow`: Sim agent jobs have no workflow of their own. Cancel/retry
65+
* keep using `hasWorkflow` so their gating is unchanged.
66+
*/
67+
const hasOpenableWorkflow = Boolean(log && resolveLogWorkflowId(log))
6168
const isCancellable =
6269
(log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow
6370
const isRetryable = log?.status === 'failed' && hasWorkflow && log?.trigger !== 'mothership'
@@ -112,7 +119,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
112119
</DropdownMenuItem>
113120

114121
<DropdownMenuSeparator />
115-
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onOpenWorkflow}>
122+
<DropdownMenuItem disabled={!hasOpenableWorkflow} onSelect={onOpenWorkflow}>
116123
<SquareArrowUpRight />
117124
Open Workflow
118125
</DropdownMenuItem>

apps/sim/app/workspace/[workspaceId]/logs/logs.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ import {
9292
getDisplayStatus,
9393
type LogStatus,
9494
parseDuration,
95+
resolveLogWorkflowId,
9596
STATUS_CONFIG,
9697
StatusBadge,
9798
TriggerBadge,
99+
workflowEditorPath,
98100
} from './utils'
99101

100102
const LOGS_PER_PAGE = 50 as const
@@ -524,9 +526,9 @@ export default function Logs() {
524526
}, [contextMenuLog, workspaceId])
525527

526528
const handleOpenWorkflow = useCallback(() => {
527-
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
529+
const wfId = contextMenuLog ? resolveLogWorkflowId(contextMenuLog) : null
528530
if (wfId) {
529-
window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank')
531+
window.open(workflowEditorPath(workspaceId, wfId), '_blank')
530532
}
531533
}, [contextMenuLog, workspaceId])
532534

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { resolveLogWorkflowId, workflowEditorPath } from './utils'
6+
7+
describe('resolveLogWorkflowId', () => {
8+
it('returns the nested workflow id when present', () => {
9+
expect(
10+
resolveLogWorkflowId({ trigger: 'manual', workflowId: 'wf-1', workflow: { id: 'wf-1' } })
11+
).toBe('wf-1')
12+
})
13+
14+
it('falls back to workflowId when the workflow object is absent', () => {
15+
expect(resolveLogWorkflowId({ trigger: 'api', workflowId: 'wf-2', workflow: null })).toBe(
16+
'wf-2'
17+
)
18+
})
19+
20+
it('prefers the nested workflow id over workflowId when both are set', () => {
21+
expect(
22+
resolveLogWorkflowId({ trigger: 'manual', workflowId: 'stale', workflow: { id: 'fresh' } })
23+
).toBe('fresh')
24+
})
25+
26+
it('returns null for Sim agent jobs even when a workflow id exists', () => {
27+
expect(
28+
resolveLogWorkflowId({
29+
trigger: 'mothership',
30+
workflowId: 'wf-3',
31+
workflow: { id: 'wf-3' },
32+
})
33+
).toBeNull()
34+
})
35+
36+
it('returns null for a deleted workflow (both id fields empty)', () => {
37+
expect(resolveLogWorkflowId({ trigger: 'manual', workflowId: null, workflow: null })).toBeNull()
38+
})
39+
40+
it('returns null when ids are present but empty strings', () => {
41+
expect(
42+
resolveLogWorkflowId({ trigger: 'manual', workflowId: '', workflow: { id: '' } })
43+
).toBeNull()
44+
})
45+
46+
it('treats a missing trigger as a normal workflow run', () => {
47+
expect(resolveLogWorkflowId({ workflowId: 'wf-4' })).toBe('wf-4')
48+
})
49+
})
50+
51+
describe('workflowEditorPath', () => {
52+
it('builds the workspace-scoped editor path', () => {
53+
expect(workflowEditorPath('ws-1', 'wf-1')).toBe('/workspace/ws-1/w/wf-1')
54+
})
55+
})

apps/sim/app/workspace/[workspaceId]/logs/utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@ export const LOG_COLUMNS = {
1717

1818
export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'
1919

20+
/**
21+
* Resolves the workflow a log row points at, or null when there is nowhere to
22+
* navigate. Sim agent jobs have no workflow of their own, and a deleted
23+
* workflow leaves both id fields empty.
24+
*
25+
* Single source of truth for "is this log's workflow reachable" — the list row,
26+
* its context menu, and the details panel must agree, or a row can render as
27+
* "Deleted Workflow" while still linking somewhere.
28+
*/
29+
export function resolveLogWorkflowId(log: {
30+
trigger?: string | null
31+
workflowId?: string | null
32+
workflow?: { id?: string } | null
33+
}): string | null {
34+
if (log.trigger === 'mothership') return null
35+
return log.workflow?.id || log.workflowId || null
36+
}
37+
38+
/** Path to a workflow in the editor. */
39+
export function workflowEditorPath(workspaceId: string, workflowId: string): string {
40+
return `/workspace/${workspaceId}/w/${workflowId}`
41+
}
42+
2043
export type LogStatus =
2144
| 'error'
2245
| 'pending'

0 commit comments

Comments
 (0)