mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-12 23:33:47 +08:00
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:
@@ -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>
|
||||
|
||||
|
||||
+8
-1
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user