'use client'; import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import type { ModelMessage } from 'ai'; import { format } from 'date-fns'; import { Lock } from 'lucide-react'; import type { KeyboardEvent, ReactNode } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react'; import { isEncryptedMarker, isExpiredMarker } from '../../lib/hydration'; import { useToast } from '../../lib/toast'; import { extractConversation, isDoStreamStep } from '../../lib/utils'; import { ContextCardProvider } from '../ui/context-card'; import { DecryptClickContext, StreamClickContext } from '../ui/data-inspector'; import { ErrorCard } from '../ui/error-card'; import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block'; import { Skeleton } from '../ui/skeleton'; import { Spinner } from '../ui/spinner'; import { TimestampTooltip } from '../ui/timestamp-tooltip'; import { ConversationView } from './conversation-view'; import { CopyableDataBlock } from './copyable-data-block'; import { DetailCard } from './detail-card'; /** * Tab button for conversation/JSON toggle */ function TabButton({ active, onClick, children, }: { active: boolean; onClick: () => void; children: ReactNode; }) { return ( ); } /** * Shared tabbed container with accessible ARIA roles and keyboard navigation. * Used by ConversationWithTabs for the conversation/JSON toggle. */ function TabbedContainer({ tabs, activeTab, onTabChange, ariaLabel, children, }: { tabs: { id: T; label: string }[]; activeTab: T; onTabChange: (tab: T) => void; ariaLabel: string; children: ReactNode; }) { const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; event.preventDefault(); const currentIndex = tabs.findIndex((t) => t.id === activeTab); const nextIndex = event.key === 'ArrowRight' ? (currentIndex + 1) % tabs.length : (currentIndex - 1 + tabs.length) % tabs.length; onTabChange(tabs[nextIndex].id); }, [tabs, activeTab, onTabChange] ); return (
{tabs.map((tab) => ( onTabChange(tab.id)} > {tab.label} ))}
{children}
); } const conversationTabs = [ { id: 'conversation' as const, label: 'Conversation' }, { id: 'json' as const, label: 'Raw JSON' }, ]; /** * Tabbed view for conversation and raw JSON */ function ConversationWithTabs({ conversation, args, }: { conversation: ModelMessage[]; args: unknown[]; }) { const [activeTab, setActiveTab] = useState<'conversation' | 'json'>( 'conversation' ); return ( {activeTab === 'conversation' ? ( ) : (
{Array.isArray(args) ? args.map((v, i) => (
{JsonBlock(v)}
)) : JsonBlock(args)}
)}
); } /** * Render a value with the shared DataInspector (ObjectInspector with * custom theming, nodeRenderer for StreamRef/ClassInstanceRef, etc.) */ /** * Inline display for an encrypted field — no expand, just a flat label * with the lucide Lock icon matching the title bar Decrypt button. */ function EncryptedFieldBlock() { const ctx = useContext(DecryptClickContext); if (ctx) { return ( ); } return (
Encrypted
); } /** * Inline display for an expired field — flat label indicating data is no longer available. */ function ExpiredFieldBlock() { return (
Data expired
); } function JsonBlock(value: unknown) { return ; } const hasDisplayContent = (value: unknown): boolean => { if (value == null) return false; if (typeof value === 'string') return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; if (typeof value === 'object') return Object.keys(value).length > 0; return true; }; type AttributeKey = | keyof Step | keyof WorkflowRun | keyof Hook | keyof Event | 'occurredAt' | 'moduleSpecifier' | 'eventData' | 'resumeAt' | 'expiredAt' | 'workflowCoreVersion' | 'receivedCount' | 'lastReceivedAt' | 'disposedAt'; const attributeOrder: AttributeKey[] = [ 'workflowName', 'moduleSpecifier', 'stepName', 'status', 'stepId', 'hookId', 'eventId', 'runId', 'attempt', 'token', 'receivedCount', 'lastReceivedAt', 'disposedAt', 'correlationId', 'eventType', 'deploymentId', 'specVersion', 'workflowCoreVersion', 'ownerId', 'projectId', 'environment', 'executionContext', 'occurredAt', 'createdAt', 'startedAt', 'updatedAt', 'completedAt', 'expiredAt', 'retryAfter', 'error', 'metadata', 'eventData', 'input', 'output', 'resumeAt', ]; const sortByAttributeOrder = (a: string, b: string): number => { const aIndex = attributeOrder.indexOf(a as AttributeKey) || 0; const bIndex = attributeOrder.indexOf(b as AttributeKey) || 0; return aIndex - bIndex; }; /** * Display names for attributes that should render differently from their key. */ const attributeDisplayNames: Partial> = { workflowCoreVersion: '@workflow/core version', occurredAt: 'Occurred', receivedCount: 'times resolved', }; /** * Get the display name for an attribute key. */ const getAttributeDisplayName = (attribute: string): string => { return attributeDisplayNames[attribute as AttributeKey] ?? attribute; }; const getModuleSpecifierFromName = (value: unknown): string => { const raw = String(value); const parsedStep = parseStepName(raw); if (parsedStep) { return parsedStep.moduleSpecifier; } const parsedWorkflow = parseWorkflowName(raw); if (parsedWorkflow) { return parsedWorkflow.moduleSpecifier; } return raw; }; const parseDateValue = (value: unknown): Date | null => { if (value == null) { return null; } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value; } if (typeof value === 'string' && value.trim().length === 0) { return null; } const date = typeof value === 'number' ? new Date(value) : new Date(String(value)); return Number.isNaN(date.getTime()) ? null : date; }; const formatLocalMillisecondTime = (date: Date): string => format(date, 'MMM dd HH:mm:ss.SS OO').toUpperCase(); export const localMillisecondTime = (value: unknown): string => { const date = parseDateValue(value); if (!date) { return '-'; } return formatLocalMillisecondTime(date); }; const localMillisecondTimeOrNull = (value: unknown): string | null => { const date = parseDateValue(value); if (!date) { return null; } return formatLocalMillisecondTime(date); }; const timestampWithTooltipOrNull = (value: unknown): ReactNode | null => { const date = parseDateValue(value); if (!date) return null; return ( {formatLocalMillisecondTime(date)} ); }; interface DisplayContext { stepName?: string; } const attributeToDisplayFn: Record< AttributeKey, (value: unknown, context?: DisplayContext) => null | string | ReactNode > = { // Names that need pretty-printing workflowName: (value: unknown) => parseWorkflowName(String(value))?.shortName ?? '?', moduleSpecifier: (value: unknown) => getModuleSpecifierFromName(value), stepName: (value: unknown) => parseStepName(String(value))?.shortName ?? '?', // IDs runId: (value: unknown) => String(value), stepId: (value: unknown) => String(value), hookId: (value: unknown) => String(value), eventId: (value: unknown) => String(value), // Run/step details status: (value: unknown) => String(value), attempt: (value: unknown) => String(value), // Hook details token: (value: unknown) => String(value), isWebhook: (value: unknown) => String(value), receivedCount: (value: unknown) => String(value), lastReceivedAt: localMillisecondTimeOrNull, disposedAt: localMillisecondTimeOrNull, // Event details eventType: (value: unknown) => String(value), correlationId: (value: unknown) => String(value), // Project details deploymentId: (value: unknown) => String(value), specVersion: (value: unknown) => String(value), workflowCoreVersion: (value: unknown) => String(value), // Tenancy (we don't show these) ownerId: (_value: unknown) => null, projectId: (_value: unknown) => null, environment: (_value: unknown) => null, executionContext: (_value: unknown) => null, // Dates — wrapped with TimestampTooltip showing UTC/local + relative time occurredAt: timestampWithTooltipOrNull, createdAt: timestampWithTooltipOrNull, startedAt: timestampWithTooltipOrNull, updatedAt: timestampWithTooltipOrNull, completedAt: timestampWithTooltipOrNull, expiredAt: timestampWithTooltipOrNull, retryAfter: timestampWithTooltipOrNull, resumeAt: timestampWithTooltipOrNull, // Resolved attributes, won't actually use this function metadata: (value: unknown) => { if (!hasDisplayContent(value)) return null; if (isEncryptedMarker(value)) return ; if (isExpiredMarker(value)) return ; return JsonBlock(value); }, input: (value: unknown, context?: DisplayContext) => { if (isEncryptedMarker(value)) return ; if (isExpiredMarker(value)) return ; // Check if input has args + closure vars structure if (value && typeof value === 'object' && 'args' in value) { const { args, closureVars, thisVal } = value as { args: unknown[]; closureVars?: Record; thisVal?: unknown; }; const argCount = Array.isArray(args) ? args.length : 0; const argLabel = argCount === 1 ? 'argument' : 'arguments'; const hasClosureVars = hasDisplayContent(closureVars); const hasThisVal = hasDisplayContent(thisVal); const hasArgs = hasDisplayContent(args); // Check if this is a doStreamStep - show conversation view with tabs if (context?.stepName && isDoStreamStep(context.stepName)) { const conversation = extractConversation(args); if (conversation && conversation.length > 0) { return ( <> {hasClosureVars && ( {JsonBlock(closureVars)} )} {hasThisVal && ( {JsonBlock(thisVal)} )} ); } } // Don't render an empty "Input (0 arguments)" card when no input exists. if (!hasArgs && !hasClosureVars && !hasThisVal) { return ( ); } return ( <> {Array.isArray(args) ? args.map((v, i) => (
{JsonBlock(v)}
)) : JsonBlock(args)}
{hasClosureVars && ( {JsonBlock(closureVars)} )} {hasThisVal && ( {JsonBlock(thisVal)} )} ); } // Fallback: treat as plain array or object const argCount = Array.isArray(value) ? value.length : 0; const argLabel = argCount === 1 ? 'argument' : 'arguments'; if (!hasDisplayContent(value)) { return ( ); } return ( {Array.isArray(value) ? value.map((v, i) => (
{JsonBlock(v)}
)) : JsonBlock(value)}
); }, output: (value: unknown) => { if (!hasDisplayContent(value)) return null; if (isEncryptedMarker(value)) return ; if (isExpiredMarker(value)) return ; return ( {JsonBlock(value)} ); }, error: (value: unknown) => { if (isEncryptedMarker(value)) return ; if (isExpiredMarker(value)) return ; if (!hasDisplayContent(value)) return null; // Structured workflow errors may be persisted as either `{ message }` or // `{ message, stack }`. Render both with the dedicated error block. if (isStructuredError(value)) { return ( ); } return ( {JsonBlock(value)} ); }, eventData: (value: unknown) => { if (isEncryptedMarker(value)) return ; if (isExpiredMarker(value)) return ; if (!hasDisplayContent(value)) return null; return {JsonBlock(value)}; }, }; const resolvableAttributes = [ 'input', 'output', 'error', 'metadata', 'eventData', ]; const ExpiredDataMessage = () => (
The data for this run has expired and is no longer available.
); export const AttributeBlock = ({ attribute, value, isLoading, inline = false, context, }: { attribute: string; value: unknown; isLoading?: boolean; inline?: boolean; context?: DisplayContext; }) => { const isExpandableLoadingTarget = attribute === 'input' || attribute === 'output' || attribute === 'eventData'; if (isLoading && isExpandableLoadingTarget && !hasDisplayContent(value)) { return (
{attribute}
); } const displayFn = attributeToDisplayFn[attribute as keyof typeof attributeToDisplayFn]; if (!displayFn) { return null; } const displayValue = displayFn(value, context); if (!displayValue) { return null; } if (inline) { return (
{attribute} {displayValue}
); } return (
{typeof isLoading === 'boolean' && isLoading && (
)}
{attribute} {displayValue}
); }; export const AttributePanel = ({ data, moduleSpecifier, isLoading, error, expiredAt, onStreamClick, onDecrypt, isDecrypting = false, resource, }: { data: Record; moduleSpecifier?: string; isLoading?: boolean; error?: Error; expiredAt?: string | Date; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; /** Callback when an encrypted marker is clicked (triggers decryption) */ onDecrypt?: () => void; /** Whether decryption is currently in progress */ isDecrypting?: boolean; /** Resource type of the selected span — used to show targeted loading skeletons. */ resource?: string; }) => { const toast = useToast(); // Extract workflowCoreVersion from executionContext for display const displayData = useMemo(() => { const result = { ...data }; const execCtx = data.executionContext as | Record | undefined; if (execCtx?.workflowCoreVersion) { result.workflowCoreVersion = execCtx.workflowCoreVersion; } if (moduleSpecifier) { result.moduleSpecifier = moduleSpecifier; } else if (typeof data.stepName === 'string') { result.moduleSpecifier = data.stepName; } else if (typeof data.workflowName === 'string') { result.moduleSpecifier = data.workflowName; } return result; }, [data, moduleSpecifier]); const hasExpired = expiredAt != null && new Date(expiredAt) < new Date(); const basicAttributes = Object.keys(displayData) .filter((key) => !resolvableAttributes.includes(key)) .sort(sortByAttributeOrder); const resolvedAttributes = useMemo(() => { const present = Object.keys(displayData) .filter((key) => resolvableAttributes.includes(key)) .sort(sortByAttributeOrder); if (!isLoading) return present; // During loading, ensure input/output appear so their skeletons render // in the correct position (above the events section). const loadingDefaults = ['input', 'output']; for (const key of loadingDefaults) { if (!present.includes(key)) { present.push(key); } } return present.sort(sortByAttributeOrder); }, [displayData, isLoading]); // Filter out attributes that return null const visibleBasicAttributes = basicAttributes.filter((attribute) => { const displayFn = attributeToDisplayFn[attribute as keyof typeof attributeToDisplayFn]; if (!displayFn) return false; const displayValue = displayFn( displayData[attribute as keyof typeof displayData] ); return displayValue !== null; }); // Keep `moduleSpecifier` immediately after `workflowName` or `stepName`. const orderedBasicAttributes = useMemo(() => { const attributes = [...visibleBasicAttributes]; const moduleSpecifierIndex = attributes.indexOf('moduleSpecifier'); if (moduleSpecifierIndex === -1) { return attributes; } attributes.splice(moduleSpecifierIndex, 1); const workflowNameIndex = attributes.indexOf('workflowName'); if (workflowNameIndex !== -1) { attributes.splice(workflowNameIndex + 1, 0, 'moduleSpecifier'); return attributes; } const stepNameIndex = attributes.indexOf('stepName'); if (stepNameIndex !== -1) { attributes.splice(stepNameIndex + 1, 0, 'moduleSpecifier'); return attributes; } attributes.unshift('moduleSpecifier'); return attributes; }, [visibleBasicAttributes]); // Memoize context object to avoid object reconstruction on render const displayContext = useMemo( () => ({ stepName: displayData.stepName as string | undefined, }), [displayData.stepName] ); const handleCopyModuleSpecifier = useCallback((value: string) => { navigator.clipboard .writeText(value) .then(() => { toast.success('moduleSpecifier copied'); }) .catch(() => { toast.error('Failed to copy moduleSpecifier'); }); }, []); return (
{/* Basic attributes in a vertical layout with border */} {visibleBasicAttributes.length > 0 && (
{orderedBasicAttributes.map((attribute, index) => { const displayValue = attributeToDisplayFn[ attribute as keyof typeof attributeToDisplayFn ]?.(displayData[attribute as keyof typeof displayData]); const isModuleSpecifier = attribute === 'moduleSpecifier'; const moduleSpecifierValue = typeof displayValue === 'string' ? displayValue : String( displayValue ?? displayData.moduleSpecifier ?? '' ); const shouldCapitalizeLabel = attribute !== 'workflowCoreVersion'; const showResumeAtSkeleton = isLoading && resource === 'sleep' && !displayData.resumeAt; const showDivider = index < orderedBasicAttributes.length - 1 || showResumeAtSkeleton; return (
{getAttributeDisplayName(attribute)} {isModuleSpecifier ? ( ) : ( {displayValue} )}
{showDivider ? (
) : null}
); })} {isLoading && resource === 'sleep' && !displayData.resumeAt && (
resumeAt
)}
)} {error ? ( ) : hasExpired ? ( ) : ( <> {resolvedAttributes.map((attribute) => ( ))} )}
); };