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.
This commit is contained in:
mzxchandra
2026-08-04 22:27:50 -07:00
committed by GitHub
parent 020ec68cfe
commit 5718def4fd
5 changed files with 132 additions and 12 deletions
@@ -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
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
{log.trigger === 'mothership' ? 'Job' : 'Workflow'}
</span>
<div className='flex min-w-0 items-center gap-1.5'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
{log.trigger === 'mothership'
? log.jobTitle || 'Untitled Job'
: log.workflow?.name ||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
</span>
</div>
{openableWorkflowId ? (
<Link
href={workflowEditorPath(workspaceId, openableWorkflowId)}
target='_blank'
rel='noopener noreferrer'
prefetch={false}
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)]'
>
<span className='inline-grid size-[14px] shrink-0 place-items-center'>
<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' />
<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' />
</span>
<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)]'>
{workflowLabel}
</span>
<span className='sr-only'>(opens in a new tab)</span>
</Link>
) : (
<div className='flex min-w-0 items-center gap-1.5'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
{workflowLabel}
</span>
</div>
)}
</div>
</div>
@@ -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({
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onOpenWorkflow}>
<DropdownMenuItem disabled={!hasOpenableWorkflow} onSelect={onOpenWorkflow}>
<SquareArrowUpRight />
Open Workflow
</DropdownMenuItem>
@@ -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])
@@ -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')
})
})
@@ -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'