chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,393 @@
import type { PreviewWorkflow } from './workflow-data'
/**
* Workflows shown in the academy videos, reproduced block-for-block so each
* page's written supplement shows the same machine the video builds. Rows and
* operation labels match the videos (which match the block registry).
*/
/** files/intro + files/object — the invoice intake machine. */
export const AV_INVOICE_INTAKE_WORKFLOW: PreviewWorkflow = {
id: 'av-invoice-intake',
name: 'Invoice intake',
blocks: [
{
id: 'gmailtrigger',
name: 'Gmail Email Trigger',
type: 'gmail',
bgColor: '#E0E0E0',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [{ title: 'Include Attachments', value: 'Enabled' }],
},
{
id: 'file',
name: 'File',
type: 'file',
bgColor: '#40916C',
position: { x: 340, y: 0 },
rows: [
{ title: 'Operation', value: 'Read' },
{ title: 'Files', value: '<gmailtrigger.attachments[0]>' },
],
},
{
id: 'agent',
name: 'Agent',
type: 'agent',
bgColor: '#33C482',
position: { x: 680, y: 0 },
rows: [
{ title: 'Messages', value: 'Extract the invoice fields' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
{ title: 'Files', value: '<file.files[0]>' },
],
},
{
id: 'supabase',
name: 'Supabase',
type: 'supabase',
bgColor: '#1C1C1C',
position: { x: 1020, y: 0 },
rows: [
{ title: 'Operation', value: 'Create a Row' },
{ title: 'Table', value: 'invoices' },
{ title: 'Data', value: '<agent.content>' },
],
},
],
edges: [
{ id: 'trigger-file', source: 'gmailtrigger', target: 'file' },
{ id: 'file-agent', source: 'file', target: 'agent' },
{ id: 'agent-supabase', source: 'agent', target: 'supabase' },
],
}
/** agents/block — the Qualify agent the camera rides through. */
export const AV_QUALIFY_WORKFLOW: PreviewWorkflow = {
id: 'av-qualify',
name: 'Qualify a lead',
blocks: [
{
id: 'start',
name: 'Start',
type: 'start_trigger',
bgColor: '#2FB3FF',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [{ title: 'Input', value: 'Lead' }],
},
{
id: 'qualify',
name: 'Qualify',
type: 'agent',
bgColor: '#33C482',
position: { x: 340, y: 0 },
rows: [
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
],
},
{
id: 'response',
name: 'Response',
type: 'response',
bgColor: '#2F55FF',
position: { x: 680, y: 0 },
rows: [{ title: 'Data', value: '<qualify.content>' }],
},
],
edges: [
{ id: 'start-qualify', source: 'start', target: 'qualify' },
{ id: 'qualify-response', source: 'qualify', target: 'response' },
],
}
/** agents/memory — the Support agent with Memory set to Conversation. */
export const AV_MEMORY_WORKFLOW: PreviewWorkflow = {
id: 'av-memory',
name: 'Support agent with memory',
blocks: [
{
id: 'start',
name: 'Start',
type: 'start_trigger',
bgColor: '#2FB3FF',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [{ title: 'Input', value: 'Customer message' }],
},
{
id: 'support',
name: 'Support',
type: 'agent',
bgColor: '#33C482',
position: { x: 340, y: 0 },
rows: [
{ title: 'Messages', value: '<start.input>' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
{ title: 'Memory', value: 'Conversation · user-123' },
],
},
],
edges: [{ id: 'start-support', source: 'start', target: 'support' }],
}
/** tables/operations — the Table block with a filtered query. */
export const AV_TABLE_OPS_WORKFLOW: PreviewWorkflow = {
id: 'av-table-ops',
name: 'Query the tickets table',
blocks: [
{
id: 'start',
name: 'Start',
type: 'start_trigger',
bgColor: '#2FB3FF',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [{ title: 'Input', value: 'Run' }],
},
{
id: 'table',
name: 'Table 1',
type: 'table',
bgColor: '#10B981',
position: { x: 340, y: 0 },
rows: [
{ title: 'Operation', value: 'Query Rows' },
{ title: 'Table', value: 'tickets' },
{ title: 'Filter Conditions', value: 'priority equals high' },
],
},
],
edges: [{ id: 'start-table', source: 'start', target: 'table' }],
}
/** agents/tool-calling — the Qualify agent with research tools attached. */
export const AV_TOOLS_WORKFLOW: PreviewWorkflow = {
id: 'av-tools',
name: 'Qualify with tools',
blocks: [
{
id: 'start',
name: 'Start',
type: 'start_trigger',
bgColor: '#2FB3FF',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [{ title: 'Input', value: 'Lead' }],
},
{
id: 'qualify',
name: 'Qualify',
type: 'agent',
bgColor: '#33C482',
position: { x: 340, y: 0 },
rows: [
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
],
tools: [
{ type: 'exa', name: 'Search', bgColor: '#1F40ED' },
{ type: 'github', name: 'GitHub', bgColor: '#181717' },
{ type: 'hubspot', name: 'CRM', bgColor: '#FF7A59' },
],
},
{
id: 'response',
name: 'Response',
type: 'response',
bgColor: '#2F55FF',
position: { x: 680, y: 0 },
rows: [{ title: 'Data', value: '<qualify.content>' }],
},
],
edges: [
{ id: 'start-qualify', source: 'start', target: 'qualify' },
{ id: 'qualify-response', source: 'qualify', target: 'response' },
],
}
/** agents/skills — the same agent with a skill attached. */
export const AV_SKILLS_WORKFLOW: PreviewWorkflow = {
id: 'av-skills',
name: 'Qualify with a skill',
blocks: [
{
id: 'start',
name: 'Start',
type: 'start_trigger',
bgColor: '#2FB3FF',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [{ title: 'Input', value: 'Lead' }],
},
{
id: 'qualify',
name: 'Qualify',
type: 'agent',
bgColor: '#33C482',
position: { x: 340, y: 0 },
rows: [
{ title: 'Messages', value: 'Qualify this lead: <start.input>' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
{ title: 'Skills', value: 'answer-with-citations' },
],
tools: [{ type: 'exa', name: 'Search', bgColor: '#1F40ED' }],
},
],
edges: [{ id: 'start-qualify', source: 'start', target: 'qualify' }],
}
/** chat/intro — the support-desk workflow the chat operates. */
export const AV_SUPPORT_DESK_WORKFLOW: PreviewWorkflow = {
id: 'av-support-desk',
name: 'support-desk',
blocks: [
{
id: 'start',
name: 'Start',
type: 'start_trigger',
bgColor: '#2FB3FF',
position: { x: 0, y: 60 },
hideTargetHandle: true,
rows: [{ title: 'Input', value: 'Ticket' }],
},
{
id: 'triage',
name: 'Triage',
type: 'agent',
bgColor: '#33C482',
position: { x: 320, y: 60 },
rows: [
{ title: 'Messages', value: '<start.input>' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
],
tools: [{ type: 'knowledge', name: 'Help Center', bgColor: '#00B0B0' }],
},
{
id: 'condition',
name: 'Urgent?',
type: 'condition',
bgColor: '#FF752F',
position: { x: 660, y: 60 },
rows: [],
branches: [
{ id: 'condition-if', label: 'If', value: '<triage.urgent>' },
{ id: 'condition-else', label: 'else' },
],
},
{
id: 'escalate',
name: 'Escalate',
type: 'slack',
bgColor: '#611F69',
position: { x: 1000, y: -40 },
rows: [{ title: 'Channel', value: '#support-urgent' }],
},
{
id: 'reply',
name: 'Reply',
type: 'agent',
bgColor: '#33C482',
position: { x: 1000, y: 160 },
rows: [{ title: 'Messages', value: 'Draft the reply' }],
},
{
id: 'log',
name: 'Log',
type: 'table',
bgColor: '#10B981',
position: { x: 1340, y: 60 },
rows: [
{ title: 'Operation', value: 'Insert Row' },
{ title: 'Table', value: 'tickets' },
],
},
],
edges: [
{ id: 'start-triage', source: 'start', target: 'triage' },
{ id: 'triage-condition', source: 'triage', target: 'condition' },
{
id: 'condition-escalate',
source: 'condition',
target: 'escalate',
sourceHandle: 'condition-if',
},
{ id: 'condition-reply', source: 'condition', target: 'reply', sourceHandle: 'condition-else' },
{ id: 'escalate-log', source: 'escalate', target: 'log' },
{ id: 'reply-log', source: 'reply', target: 'log' },
],
}
/** chat/building — the content-agent the chat builds: candidates drafted in
* parallel, media generated, an evaluator scoring, results kept. */
export const AV_CONTENT_AGENT_WORKFLOW: PreviewWorkflow = {
id: 'av-content-agent',
name: 'content-agent',
blocks: [
{
id: 'start',
name: 'Start',
type: 'start_trigger',
bgColor: '#2FB3FF',
position: { x: 0, y: 95 },
hideTargetHandle: true,
rows: [{ title: 'Input', value: 'Idea' }],
},
{
id: 'candidates',
name: 'Candidates',
type: 'parallel',
bgColor: '#1D1C1A',
position: { x: 320, y: 30 },
size: { width: 430, height: 170 },
rows: [],
},
{
id: 'writer',
name: 'Writer',
type: 'agent',
bgColor: '#33C482',
position: { x: 150, y: 62 },
parentId: 'candidates',
rows: [
{ title: 'Messages', value: '<start.input>' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
],
},
{
id: 'evaluator',
name: 'Evaluator',
type: 'evaluator',
bgColor: '#8B5CF6',
position: { x: 840, y: 95 },
rows: [
{ title: 'Metrics', value: 'Voice · Hook' },
{ title: 'Content', value: '<writer.content>' },
],
},
{
id: 'scores',
name: 'Scores',
type: 'table',
bgColor: '#10B981',
position: { x: 1180, y: 95 },
rows: [
{ title: 'Operation', value: 'Insert Row' },
{ title: 'Table', value: 'scores' },
],
},
],
edges: [
{ id: 'start-candidates', source: 'start', target: 'candidates' },
{
id: 'candidates-writer',
source: 'candidates',
target: 'writer',
sourceHandle: 'parallel-start-source',
},
{ id: 'candidates-evaluator', source: 'candidates', target: 'evaluator' },
{ id: 'evaluator-scores', source: 'evaluator', target: 'scores' },
],
}
@@ -0,0 +1,372 @@
import type { PreviewWorkflow } from '@/components/workflow-preview/workflow-data'
/**
* Single-block preview workflows for the block reference heroes — one per block,
* authored to match exactly what the builder canvas shows. Source of truth for
* `<BlockPreview type="...">`.
*
* Each entry is a one-block {@link PreviewWorkflow} rendered through the shared
* {@link WorkflowBlockView} (via `DocsBlockNode`), so the hero stays pixel-faithful
* to the canvas. Authoring notes:
*
* - `rows` are the visible sub-block rows; use `'-'` for an empty/unset field (the
* canvas shows a dash), or a representative value where the field has a default.
* - `branches` render one output handle per entry (Condition's if/else-if/else,
* Router's routes). The View regenerates handle topology, so the label doubles as
* the branch id.
* - `hideTargetHandle: true` for triggers (entry points — no input). The View derives
* the default target/source handles and the bottom `Error` row from this gate, so
* there is no separate `showError`/`hideSourceHandle` flag.
* - `bgColor` is the resolved hex; the Agent uses Sim green `#33C482` (`var(--brand)`).
*/
export const BLOCK_DISPLAY_WORKFLOWS: Record<string, PreviewWorkflow> = {
agent: {
id: 'agent',
name: 'Agent',
blocks: [
{
id: 'agent',
name: 'Agent',
type: 'agent',
bgColor: '#33C482',
position: { x: 0, y: 0 },
rows: [
{ title: 'Messages', value: '-' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
{ title: 'Files', value: '-' },
{ title: 'Tools', value: '-' },
{ title: 'Skills', value: '-' },
{ title: 'Memory', value: 'None' },
{ title: 'Temperature', value: '0.7' },
{ title: 'Response Format', value: '-' },
],
},
],
edges: [],
},
api: {
id: 'api',
name: 'API',
blocks: [
{
id: 'api',
name: 'API',
type: 'api',
bgColor: '#2F55FF',
position: { x: 0, y: 0 },
rows: [
{ title: 'URL', value: '-' },
{ title: 'Method', value: 'GET' },
{ title: 'Query Params', value: '-' },
{ title: 'Headers', value: '-' },
{ title: 'Body', value: '-' },
],
},
],
edges: [],
},
condition: {
id: 'condition',
name: 'Condition',
blocks: [
{
id: 'condition',
name: 'Condition',
type: 'condition',
bgColor: '#FF752F',
position: { x: 0, y: 0 },
rows: [],
branches: [
{ id: 'if', label: 'if' },
{ id: 'else if', label: 'else if' },
{ id: 'else', label: 'else' },
],
},
],
edges: [],
},
credential: {
id: 'credential',
name: 'Credential',
blocks: [
{
id: 'credential',
name: 'Credential',
type: 'credential',
bgColor: '#6366F1',
position: { x: 0, y: 0 },
rows: [
{ title: 'Operation', value: 'Select Credential' },
{ title: 'Credential', value: '-' },
],
},
],
edges: [],
},
evaluator: {
id: 'evaluator',
name: 'Evaluator',
blocks: [
{
id: 'evaluator',
name: 'Evaluator',
type: 'evaluator',
bgColor: '#4D5FFF',
position: { x: 0, y: 0 },
rows: [
{ title: 'Evaluation Metrics', value: '-' },
{ title: 'Content', value: '-' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
],
},
],
edges: [],
},
function: {
id: 'function',
name: 'Function',
blocks: [
{
id: 'function',
name: 'Function',
type: 'function',
bgColor: '#FF402F',
position: { x: 0, y: 0 },
rows: [
{ title: 'Language', value: 'JavaScript' },
{ title: 'Code', value: '-' },
],
},
],
edges: [],
},
guardrails: {
id: 'guardrails',
name: 'Guardrails',
blocks: [
{
id: 'guardrails',
name: 'Guardrails',
type: 'guardrails',
bgColor: '#3D642D',
position: { x: 0, y: 0 },
rows: [
{ title: 'Content to Validate', value: '-' },
{ title: 'Validation Type', value: 'Valid JSON' },
],
},
],
edges: [],
},
response: {
id: 'response',
name: 'Response',
blocks: [
{
id: 'response',
name: 'Response',
type: 'response',
bgColor: '#2F55FF',
position: { x: 0, y: 0 },
rows: [
{ title: 'Response Data Mode', value: 'Builder' },
{ title: 'Response Structure', value: '-' },
{ title: 'Status Code', value: '-' },
{ title: 'Response Headers', value: '-' },
],
},
],
edges: [],
},
router: {
id: 'router',
name: 'Router',
blocks: [
{
id: 'router',
name: 'Router',
type: 'router',
bgColor: '#28C43F',
position: { x: 0, y: 0 },
rows: [
{ title: 'Context', value: '-' },
{ title: 'Model', value: 'claude-sonnet-4-6' },
],
branches: [{ id: 'route 1', label: 'route 1' }],
},
],
edges: [],
},
variables: {
id: 'variables',
name: 'Variables',
blocks: [
{
id: 'variables',
name: 'Variables',
type: 'variables',
bgColor: '#8B5CF6',
position: { x: 0, y: 0 },
rows: [{ title: 'Variable Assignments', value: '-' }],
},
],
edges: [],
},
wait: {
id: 'wait',
name: 'Wait',
blocks: [
{
id: 'wait',
name: 'Wait',
type: 'wait',
bgColor: '#F59E0B',
position: { x: 0, y: 0 },
rows: [
{ title: 'Wait Amount', value: '10' },
{ title: 'Unit', value: 'Seconds' },
{ title: 'Async', value: '-' },
],
},
],
edges: [],
},
webhook: {
id: 'webhook',
name: 'Webhook',
blocks: [
{
id: 'webhook',
name: 'Webhook',
type: 'webhook',
bgColor: '#10B981',
position: { x: 0, y: 0 },
rows: [
{ title: 'Webhook URL', value: '-' },
{ title: 'Payload', value: '-' },
{ title: 'Signing Secret', value: '-' },
{ title: 'Additional Headers', value: '-' },
],
},
],
edges: [],
},
workflow: {
id: 'workflow',
name: 'Workflow',
blocks: [
{
id: 'workflow',
name: 'Workflow',
type: 'workflow',
bgColor: '#6366F1',
position: { x: 0, y: 0 },
rows: [
{ title: 'Select Workflow', value: '-' },
{ title: 'Input Variable', value: '-' },
],
},
],
edges: [],
},
human_in_the_loop: {
id: 'human_in_the_loop',
name: 'Human in the Loop',
blocks: [
{
id: 'human_in_the_loop',
name: 'Human in the Loop',
type: 'human_in_the_loop',
bgColor: '#10B981',
position: { x: 0, y: 0 },
rows: [
{ title: 'Display Data', value: '-' },
{ title: 'Notification (Send URL)', value: '-' },
{ title: 'Resume Form', value: '-' },
],
},
],
edges: [],
},
schedule: {
id: 'schedule',
name: 'Schedule',
blocks: [
{
id: 'schedule',
name: 'Schedule',
type: 'schedule',
bgColor: '#6366F1',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [
{ title: 'Run frequency', value: 'Daily' },
{ title: 'Time', value: '-' },
{ title: 'Timezone', value: '-' },
],
},
],
edges: [],
},
rss: {
id: 'rss',
name: 'RSS Feed',
blocks: [
{
id: 'rss',
name: 'RSS Feed',
type: 'rss',
bgColor: '#F97316',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [{ title: 'Feed URL', value: '-' }],
},
],
edges: [],
},
webhook_trigger: {
id: 'webhook_trigger',
name: 'Webhook',
blocks: [
{
id: 'webhook_trigger',
name: 'Webhook',
type: 'webhook',
bgColor: '#10B981',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [
{ title: 'Webhook URL', value: '-' },
{ title: 'Require Authentication', value: 'On' },
{ title: 'Authentication Token', value: '-' },
{ title: 'Secret Header Name (Optional)', value: '-' },
{ title: 'Deduplication Field (Optional)', value: '-' },
{ title: 'Acknowledgement', value: 'Default' },
{ title: 'Verify Test Events', value: '-' },
{ title: 'Input Format', value: '-' },
],
},
],
edges: [],
},
table: {
id: 'table',
name: 'Table',
blocks: [
{
id: 'table',
name: 'Table',
type: 'table',
bgColor: '#10B981',
position: { x: 0, y: 0 },
hideTargetHandle: true,
rows: [
{ title: 'Table', value: '-' },
{ title: 'Event type', value: 'Row updated' },
{ title: 'Watch columns', value: '-' },
],
},
],
edges: [],
},
}
@@ -0,0 +1,113 @@
import type { ComponentType, SVGProps } from 'react'
import { Clock, Database, Layers, Repeat, Table } from 'lucide-react'
import {
ApiIcon,
ChartBarIcon,
CodeIcon,
ConditionalIcon,
ConnectIcon,
CredentialIcon,
HumanInTheLoopIcon,
ResponseIcon,
RssIcon,
ScheduleIcon,
ShieldCheckIcon,
VariableIcon,
WebhookIcon,
WorkflowIcon,
} from '@/components/icons'
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
/**
* The two Sim-specific block glyphs we need, ported verbatim from
* `apps/sim/components/icons.tsx` so the preview matches the real builder.
* Other block types fall back to lucide-react stand-ins for now.
*/
export function StartIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='26'
height='16'
viewBox='0 0 26 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M7.8 13C9.23 13 10.45 12.49 11.47 11.47C12.49 10.45 13 9.23 13 7.8C13 6.37 12.49 5.15 11.47 4.13C10.45 3.11 9.23 2.6 7.8 2.6C6.37 2.6 5.15 3.11 4.13 4.13C3.11 5.15 2.6 6.37 2.6 7.8C2.6 9.23 3.11 10.45 4.13 11.47C5.15 12.49 6.37 13 7.8 13ZM7.8 15.6C5.63 15.6 3.79 14.84 2.28 13.33C0.76 11.81 0 9.97 0 7.8C0 5.63 0.76 3.79 2.28 2.28C3.79 0.76 5.63 0 7.8 0C9.75 0 11.45 0.62 12.89 1.85C14.33 3.09 15.2 4.64 15.5 6.5H24.7C25.07 6.5 25.38 6.62 25.63 6.87C25.88 7.12 26 7.43 26 7.8C26 8.17 25.87 8.48 25.63 8.73C25.38 8.98 25.07 9.1 24.7 9.1H15.5C15.2 10.96 14.33 12.51 12.89 13.75C11.44 14.98 9.75 15.6 7.8 15.6Z'
fill='currentColor'
/>
</svg>
)
}
export function AgentIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='21'
height='24'
viewBox='0 0 21 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M15.67 9.25H4.67C2.64 9.25 1 10.89 1 12.92V18.42C1 20.44 2.64 22.08 4.67 22.08H15.67C17.69 22.08 19.33 20.44 19.33 18.42V12.92C19.33 10.89 17.69 9.25 15.67 9.25Z'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M10.17 5.58C11.18 5.58 12 4.76 12 3.75C12 2.74 11.18 1.92 10.17 1.92C9.15 1.92 8.33 2.74 8.33 3.75C8.33 4.76 9.15 5.58 10.17 5.58Z'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M10.17 5.59V9.25M7.42 16.59V14.75M12.92 14.75V16.59'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
/** Block type → glyph. Brand glyphs from the app for core blocks; lucide stand-ins for the rest. */
export const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
starter: StartIcon,
start_trigger: StartIcon,
agent: AgentIcon,
api: ApiIcon,
condition: ConditionalIcon,
credential: CredentialIcon,
evaluator: ChartBarIcon,
function: CodeIcon,
guardrails: ShieldCheckIcon,
human_in_the_loop: HumanInTheLoopIcon,
response: ResponseIcon,
router: ConnectIcon,
variables: VariableIcon,
wait: Clock,
webhook: WebhookIcon,
workflow: WorkflowIcon,
schedule: ScheduleIcon,
rss: RssIcon,
loop: Repeat,
parallel: Layers,
knowledge_base: Database,
knowledge: Database,
table: Table,
}
/**
* Resolves a block (or tool) type to its glyph: the core-block map first, then
* the integration icon map so diagrams can render tool chips too. Returns
* `null` when no glyph is registered.
*/
export function resolveIcon(type: string): ComponentType<{ className?: string }> | null {
return BLOCK_ICONS[type] ?? blockTypeToIconMap[type] ?? null
}
@@ -0,0 +1,225 @@
'use client'
import {
ChipSelect,
ChipSwitch,
ChipTag,
chipFieldSurfaceClass,
chipFieldTextClass,
cn,
FieldDivider,
Label,
} from '@sim/emcn'
import { BookOpen, Pencil } from 'lucide-react'
import { resolveIcon } from '@/components/workflow-preview/block-icons'
import { formatReferences } from '@/components/workflow-preview/format-references'
type FieldKind = 'select' | 'input' | 'textarea' | 'code' | 'slider' | 'toggle'
interface InspectorField {
label: string
required?: boolean
kind?: FieldKind
/** Shown inside the control. For 'toggle', "on"/"off". For 'slider', the number. */
value?: string
/** Muted placeholder when there's no value. */
placeholder?: string
/** Slider fill, 0100. */
percent?: number
}
interface InspectorTool {
type: string
name: string
bgColor: string
}
interface BlockInspectorProps {
/** Block name in the header, e.g. "Agent 1". */
name: string
/** Block type, for the header icon. */
type?: string
color?: string
fields: InspectorField[]
tools?: InspectorTool[]
/** Render as a borderless panel filling its parent (the lightbox sidebar). */
embedded?: boolean
}
const NOOP = () => {}
/**
* Read-only facsimile of one configuration field, composed from emcn chip
* chrome: `select`→{@link ChipSelect}, `toggle`→{@link ChipSwitch}; text fields
* (`input`/`textarea`/`code`) render the value with `<...>`/`{{...}}` references
* highlighted via {@link formatReferences} in the canonical chip field surface.
* `slider` has no chip equivalent and stays a minimal app-token bar.
*/
function FieldControl({ field }: { field: InspectorField }) {
const kind = field.kind ?? 'input'
const value = field.value ?? ''
const placeholder = field.placeholder ?? '—'
if (kind === 'select') {
return (
<ChipSelect
fullWidth
value={value || undefined}
onChange={NOOP}
placeholder={placeholder}
options={value ? [{ value, label: value }] : []}
/>
)
}
if (kind === 'toggle') {
const on = field.value === 'on'
return (
<ChipSwitch
value={on ? 'on' : 'off'}
onChange={NOOP}
aria-label={field.label}
options={[
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
]}
/>
)
}
if (kind === 'slider') {
const percent = field.percent ?? 50
return (
<div className='flex w-full items-center gap-3'>
<div className='relative h-[4px] flex-1 rounded-full bg-[var(--surface-5)]'>
<div
className='absolute inset-y-0 left-0 rounded-full bg-[var(--brand-secondary)]'
style={{ width: `${percent}%` }}
/>
<div
className='-translate-y-1/2 absolute top-1/2 size-[12px] rounded-full border border-[var(--border-1)] bg-white'
style={{ left: `calc(${percent}% - 6px)` }}
/>
</div>
<span className='text-[13px] text-[var(--text-primary)]'>{field.value}</span>
</div>
)
}
// input / textarea / code: read-only value with `<...>` block references and
// `{{...}}` environment variables highlighted, in the canonical chip chrome.
const content = value ? (
formatReferences(value)
) : (
<span className='text-[var(--text-muted)]'>{placeholder}</span>
)
if (kind === 'textarea' || kind === 'code') {
return (
<div
className={cn(
chipFieldSurfaceClass,
chipFieldTextClass,
'min-h-[60px] whitespace-pre-wrap break-words px-2 py-1.5',
kind === 'code' && 'font-mono'
)}
>
{content}
</div>
)
}
return (
<div className={cn(chipFieldSurfaceClass, 'flex h-[30px] items-center px-2')}>
<span className={cn(chipFieldTextClass, 'min-w-0 truncate')}>{content}</span>
</div>
)
}
function InspectorFieldRow({ field }: { field: InspectorField }) {
return (
<div className='flex flex-col gap-2.5'>
<Label className='pl-0.5'>
{field.label}
{field.required && <span className='ml-0.5'>*</span>}
</Label>
<FieldControl field={field} />
</div>
)
}
/**
* A read-only facsimile of the editor's right-hand block inspector: the block
* header, its configuration fields as static chip controls, and its
* connections. Hand-authored per usage, like {@link WorkflowPreview} examples.
*/
export function BlockInspector({
name,
type = 'agent',
color = '#33C482',
fields,
tools,
embedded = false,
}: BlockInspectorProps) {
const Icon = resolveIcon(type)
const hasTools = Boolean(tools && tools.length > 0)
return (
<div
className={cn(
'bg-[var(--surface-1)]',
embedded
? 'flex h-full w-full flex-col overflow-y-auto'
: 'not-prose my-6 w-full max-w-[380px] overflow-hidden rounded-xl border border-[var(--border)]'
)}
>
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-3 py-1.5'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm'
style={{ background: color }}
>
{Icon && <Icon className='size-[12px] text-white' />}
</div>
<span className='truncate font-medium text-[var(--text-primary)] text-sm'>{name}</span>
</div>
<div className='flex shrink-0 items-center gap-2 text-[var(--text-secondary)]'>
<Pencil className='size-[14px]' />
<BookOpen className='size-[14px]' />
</div>
</div>
<div className='flex flex-col px-3 py-3'>
{fields.map((field, i) => (
<div key={field.label}>
{i > 0 && <FieldDivider />}
<InspectorFieldRow field={field} />
</div>
))}
{hasTools && (
<div>
{fields.length > 0 && <FieldDivider />}
<div className='flex flex-col gap-2.5'>
<Label className='pl-0.5'>Tools</Label>
<div className='flex flex-wrap gap-[6px]'>
{tools?.map((tool) => {
const TIcon = resolveIcon(tool.type)
return (
<ChipTag key={tool.type} variant='gray'>
<span
className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: tool.bgColor }}
>
{TIcon && <TIcon className='size-[9px] text-white' />}
</span>
{tool.name}
</ChipTag>
)
})}
</div>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,68 @@
'use client'
import { useMemo } from 'react'
import { domAnimation, LazyMotion } from 'framer-motion'
import ReactFlow, { type NodeTypes, ReactFlowProvider } from 'reactflow'
import 'reactflow/dist/style.css'
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
import { toReactFlowElements } from '@/components/workflow-preview/workflow-data'
/** The hero mounts the same node type the canvas uses, so it can never drift. */
const NODE_TYPES: NodeTypes = { previewBlock: DocsBlockNode }
const PRO_OPTIONS = { hideAttribution: true }
/** `maxZoom` mirrors the previous hand-rolled hero's 1.3 scale. */
const FIT_VIEW_OPTIONS = { padding: 0.2, maxZoom: 1.3 } as const
interface BlockPreviewProps {
/** Block key from {@link BLOCK_DISPLAY_WORKFLOWS} (e.g. `agent`, `condition`, `webhook_trigger`). */
type: string
}
/**
* Renders a single block exactly as it appears on the builder canvas, drawn by the
* shared {@link WorkflowBlockView} (via `DocsBlockNode`) through the same ReactFlow
* machinery as the multi-block diagrams — never a parallel hand-rolled card. Static
* and non-interactive (no pan/zoom), centered in a bordered container. Use as the hero
* on a block reference page: `<BlockPreview type="agent" />`. Edit the source data in
* `block-display-workflows.ts`.
*/
export function BlockPreview({ type }: BlockPreviewProps) {
const workflow = BLOCK_DISPLAY_WORKFLOWS[type]
const elements = useMemo(() => (workflow ? toReactFlowElements(workflow) : null), [workflow])
if (!workflow || !elements) return null
return (
<div
className='not-prose my-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
style={{ height: 400 }}
>
<LazyMotion features={domAnimation}>
<ReactFlowProvider>
<ReactFlow
nodes={elements.nodes}
edges={elements.edges}
nodeTypes={NODE_TYPES}
proOptions={PRO_OPTIONS}
fitView
fitViewOptions={FIT_VIEW_OPTIONS}
minZoom={0.2}
maxZoom={1.3}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
zoomOnScroll={false}
zoomOnDoubleClick={false}
zoomOnPinch={false}
panOnDrag={false}
panOnScroll={false}
preventScrolling={false}
className='h-full w-full'
/>
</ReactFlowProvider>
</LazyMotion>
</div>
)
}
@@ -0,0 +1,145 @@
'use client'
import { type ComponentType, memo } from 'react'
import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
import { m } from 'framer-motion'
import type { NodeProps } from 'reactflow'
import { resolveIcon } from '@/components/workflow-preview/block-icons'
import {
BLOCK_STAGGER,
EASE_OUT,
type PreviewTool,
} from '@/components/workflow-preview/workflow-data'
/** Renders the colored square with no glyph when a block type has no registered icon. */
const EMPTY_ICON: ComponentType<{ className?: string }> = () => null
const RING_STYLES = 'ring-[1.75px] ring-[var(--brand-secondary)]'
interface DocsBlockData {
name: string
blockType: string
bgColor: string
rows: Array<{ title: string; value: string }>
branches?: Array<{ id: string; label: string; value?: string }>
tools?: PreviewTool[]
hideTargetHandle?: boolean
index?: number
animate?: boolean
isHighlighted?: boolean
isDimmed?: boolean
}
/**
* Docs adapter for workflow block nodes: maps the static preview data to the
* shared {@link WorkflowBlockView}'s props. Carries no stores, hooks, or
* queries — it only reshapes data into View props and wraps the result in the
* dim/stagger motion used by the rest of the diagram (the parent
* `WorkflowPreview` provides the `LazyMotion` feature set). The block's ring is
* driven by `hasRing`/`ringStyles` inside the View.
*/
export const DocsBlockNode = memo(function DocsBlockNode({ id, data }: NodeProps<DocsBlockData>) {
const {
name,
blockType,
bgColor,
rows: dataRows,
branches,
tools,
hideTargetHandle = false,
index = 0,
animate = false,
isHighlighted = false,
isDimmed = false,
} = data
/** The View gates router handle topology on `type === 'router_v2'`. */
const type = blockType === 'router' ? 'router_v2' : blockType
const Icon = resolveIcon(blockType) ?? EMPTY_ICON
const delay = animate ? index * BLOCK_STAGGER : 0
const hasBranches = Boolean(branches && branches.length > 0)
const hasTools = Boolean(tools && tools.length > 0)
/** The View renders the default target/source/error handles (and the error row) for non-trigger blocks; mirror that gate. */
const shouldShowDefaultHandles = !hideTargetHandle
const hasContentBelowHeader =
dataRows.length > 0 || hasBranches || hasTools || shouldShowDefaultHandles
/**
* Strip the app's `condition-`/`router-` handle prefixes — the View
* regenerates them, so passing them through would double-prefix the handle id.
* Branch + router-context values render through the editor's `getDisplayValue`,
* which shows `-` for a blank value (e.g. an `else` branch); mirror that.
*/
const conditionRows =
type === 'condition'
? (branches ?? []).map((branch) => ({
id: branch.id.replace(/^condition-/, ''),
title: branch.label,
value: branch.value || '-',
}))
: []
const routerRows =
type === 'router_v2'
? (branches ?? []).map((branch) => ({
id: branch.id.replace(/^router-/, ''),
value: branch.value || '-',
}))
: []
/** The View renders the router's leading Context row from this prop, not `rows`. */
const routerContextValue =
type === 'router_v2' ? dataRows.find((row) => row.title === 'Context')?.value || '-' : undefined
/**
* Non-branch content only — the View renders condition/router/error rows from
* the conditionRows/routerRows it receives, so their order stays locked to its
* handle geometry in one place.
*/
const rows =
type === 'condition' || type === 'router_v2' ? null : (
<>
{dataRows.map((row) => (
<SubBlockRowView key={row.title} title={row.title} displayValue={row.value} />
))}
{hasTools && (
<SubBlockRowView
title='Tools'
displayValue={tools?.map((tool) => tool.name).join(', ')}
/>
)}
</>
)
return (
<m.div
className='relative transition-opacity duration-300'
style={{ opacity: isDimmed ? 0.35 : 1 }}
initial={animate ? { opacity: 0 } : false}
animate={{ opacity: isDimmed ? 0.35 : 1 }}
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
>
<WorkflowBlockView
id={id}
type={type}
name={name}
isEnabled
isLocked={false}
hasRing={Boolean(isHighlighted)}
ringStyles={RING_STYLES}
Icon={Icon}
iconBgColor={bgColor}
horizontalHandles
shouldShowDefaultHandles={shouldShowDefaultHandles}
hasContentBelowHeader={hasContentBelowHeader}
conditionRows={conditionRows}
routerRows={routerRows}
routerContextValue={routerContextValue}
wouldCreateConnectionCycle={() => false}
onSelect={() => {}}
rows={rows}
/>
</m.div>
)
})
@@ -0,0 +1,42 @@
'use client'
import { memo } from 'react'
import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
import type { NodeProps } from 'reactflow'
interface DocsContainerData {
name: string
blockType: string
size?: { width: number; height: number }
}
/**
* Docs adapter for loop/parallel container blocks: maps the static preview data
* to {@link SubflowNodeView}'s read-only `isPreview` shape. Carries no stores,
* hooks, or queries — it only reshapes data into View props.
*/
export const DocsContainerNode = memo(function DocsContainerNode({
id,
data,
}: NodeProps<DocsContainerData>) {
const subflowData: SubflowNodeData = {
kind: data.blockType === 'parallel' ? 'parallel' : 'loop',
name: data.name,
width: data.size?.width,
height: data.size?.height,
isPreview: true,
}
return (
<SubflowNodeView
id={id}
data={subflowData}
isEnabled
isLocked={false}
isFocused={false}
nestingLevel={0}
canEditWorkflow={false}
onSelect={() => {}}
/>
)
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
/** Block references `<block.field>` and environment variables `{{VAR}}`. */
const REFERENCE_PATTERN = /(<[^<>]+>|\{\{[^{}]+\}\})/g
/**
* Highlights `<...>` block references and `{{...}}` environment variables in
* brand-secondary, mirroring the editor's `formatDisplayText`. Read-only and
* static — no validation or tag interactivity, since docs has no workflow state.
*/
export function formatReferences(text: string): ReactNode[] {
if (!text) return []
return text.split(REFERENCE_PATTERN).map((part, index) => {
if (!part) return null
const isReference =
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
return isReference ? (
<span key={index} className='text-[var(--brand-secondary)]'>
{part}
</span>
) : (
<span key={index}>{part}</span>
)
})
}
@@ -0,0 +1,52 @@
export { BlockPreview } from '@/components/workflow-preview/block-preview'
export {
API_FETCH_WORKFLOW,
BUILD_AGENT_WORKFLOW,
CLASSIFY_REPLY_WORKFLOW,
CLASSIFY_WORKFLOW,
COMBINATION_WORKFLOW,
CONCURRENCY_WORKFLOW,
CONDITION_MODERATE_WORKFLOW,
CONDITION_ONBOARD_WORKFLOW,
CONDITION_ROUTE_WORKFLOW,
CREDENTIAL_ROUTE_WORKFLOW,
CREDENTIAL_SHARE_WORKFLOW,
ERROR_PATH_WORKFLOW,
EVALUATOR_GATE_WORKFLOW,
FILE_SUMMARY_WORKFLOW,
FUNCTION_RESHAPE_WORKFLOW,
FUNCTION_VALIDATE_WORKFLOW,
GUARDRAILS_HALLUCINATION_WORKFLOW,
GUARDRAILS_JSON_WORKFLOW,
GUARDRAILS_PII_WORKFLOW,
HITL_APPROVAL_WORKFLOW,
HITL_MULTISTAGE_WORKFLOW,
HITL_VALIDATE_WORKFLOW,
LEAD_SCORER_WORKFLOW,
LOOP_WORKFLOW,
PARALLEL_WORKFLOW,
RESPONSE_API_WORKFLOW,
RESPONSE_ERROR_WORKFLOW,
RESPONSE_WEBHOOK_WORKFLOW,
ROUTER_CLASSIFY_WORKFLOW,
ROUTER_LEAD_WORKFLOW,
ROUTER_TRIAGE_WORKFLOW,
ROUTING_WORKFLOW,
SUPPORT_KB_WORKFLOW,
TABLE_ENRICH_WORKFLOW,
TABLE_ROUNDTRIP_WORKFLOW,
VARIABLES_CONFIG_WORKFLOW,
VARIABLES_RETRY_WORKFLOW,
WAIT_FOLLOWUP_WORKFLOW,
WAIT_RATELIMIT_WORKFLOW,
WEBHOOK_NOTIFY_WORKFLOW,
WEBHOOK_TRIGGER_WORKFLOW,
WORKFLOW_CALL_WORKFLOW,
} from '@/components/workflow-preview/examples'
export { OutputBundle } from '@/components/workflow-preview/output-bundle'
export type {
PreviewBlock,
PreviewTool,
PreviewWorkflow,
} from '@/components/workflow-preview/workflow-data'
export { WorkflowPreview } from '@/components/workflow-preview/workflow-preview'
@@ -0,0 +1,161 @@
'use client'
import { Badge } from '@sim/emcn'
import { ChevronDown, Clipboard, Download, Search } from 'lucide-react'
import { resolveIcon } from '@/components/workflow-preview/block-icons'
type ValueType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
/** Output value type → emcn Badge color variant. */
const TYPE_VARIANT = {
string: 'green',
number: 'blue',
boolean: 'orange',
array: 'purple',
object: 'gray',
null: 'gray',
} as const
interface OutputNode {
key: string
type?: ValueType
/** Primitive value shown beneath the key when expanded. */
value?: string
/** Nested fields for object/array nodes. */
children?: OutputNode[]
/** Collapse the node (chevron points right, nothing rendered beneath). */
expanded?: boolean
/** Emphasize this row as the one being read by the tag below. */
highlight?: boolean
}
interface LogRow {
name: string
type?: string
color?: string
duration?: string
selected?: boolean
}
interface OutputBundleProps {
/** The block's name (unique within the workflow), e.g. "classify". */
blockName: string
blockType?: string
blockColor?: string
/** Duration shown on the selected log row. */
duration?: string
/** Override the Logs column; defaults to Start + this block (selected). */
logs?: LogRow[]
values: OutputNode[]
}
function TypeBadge({ type }: { type: ValueType }) {
return (
<Badge variant={TYPE_VARIANT[type]} size='sm'>
{type}
</Badge>
)
}
function TreeNode({ node, depth = 0 }: { node: OutputNode; depth?: number }) {
const type = node.type ?? 'string'
const expanded = node.expanded ?? Boolean(node.children || node.value !== undefined)
return (
<div className='flex min-w-0 flex-col'>
<div className='flex min-h-[26px] items-center gap-2 rounded-[6px] px-1'>
<span
className='text-[13px]'
style={{ color: node.highlight ? 'var(--brand-secondary)' : 'var(--text-primary)' }}
>
{node.key}
</span>
<TypeBadge type={type} />
<ChevronDown
className='h-[7px] w-[9px] flex-shrink-0 text-[var(--text-muted)]'
style={expanded ? undefined : { transform: 'rotate(-90deg)' }}
/>
</div>
{expanded && (node.children || node.value !== undefined) && (
<div className='mt-0.5 ml-[5px] flex min-w-0 flex-col gap-0.5 border-[var(--divider)] border-l pl-[10px]'>
{node.children
? node.children.map((child) => (
<TreeNode key={child.key} node={child} depth={depth + 1} />
))
: node.value !== undefined && (
<div className='py-0.5 text-[13px] text-[var(--text-secondary)]'>{node.value}</div>
)}
</div>
)}
</div>
)
}
/**
* A miniature of the app's run inspector — the Logs list beside the Output
* panel's typed tree — teaching what a block's output is: named, typed values
* remembered under the block's name, read with a `<blockName.key>` tag.
*/
export function OutputBundle({
blockName,
blockType = 'agent',
blockColor = '#33C482',
duration = '1.2s',
logs,
values,
}: OutputBundleProps) {
const logRows: LogRow[] = logs ?? [
{ name: 'Start', type: 'start_trigger', color: '#2FB3FF', duration: '9ms' },
{ name: blockName, type: blockType, color: blockColor, duration, selected: true },
]
return (
<div className='not-prose my-6 flex w-full max-w-[640px] flex-col gap-3'>
<div className='flex overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-1)]'>
<div className='flex w-[210px] flex-shrink-0 flex-col border-[var(--border)] border-r px-2 py-2'>
<div className='px-2 pb-2 text-[12px] text-[var(--text-muted)]'>Logs</div>
{logRows.map((row) => {
const Icon = row.type ? resolveIcon(row.type) : null
return (
<div
key={row.name}
className='flex h-[30px] items-center gap-2 rounded-[6px] px-2'
style={row.selected ? { background: 'var(--surface-active)' } : undefined}
>
<div
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-[5px]'
style={{ background: row.color ?? 'var(--border-1)' }}
>
{Icon && <Icon className='size-[10px] text-white' />}
</div>
<span className='truncate text-[13px] text-[var(--text-primary)]'>{row.name}</span>
{row.duration && (
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>
{row.duration}
</span>
)}
</div>
)
})}
</div>
<div className='flex min-w-0 flex-1 flex-col px-3 py-2'>
<div className='flex items-center gap-3 pb-2'>
<span className='text-[13px] text-[var(--text-primary)]'>Output</span>
<span className='text-[13px] text-[var(--text-muted)]'>Input</span>
<span className='ml-auto flex items-center gap-2 text-[var(--text-subtle)]'>
<Search className='size-[12px]' />
<Clipboard className='size-[12px]' />
<Download className='size-[12px]' />
<ChevronDown className='size-[12px]' />
</span>
</div>
<div className='flex min-w-0 flex-col gap-0.5'>
{values.map((node) => (
<TreeNode key={node.key} node={node} />
))}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,153 @@
import { type Edge, type Node, Position } from 'reactflow'
/**
* Tool entry displayed as a chip on a block (e.g. an Agent's attached tools).
*/
export interface PreviewTool {
name: string
type: string
bgColor: string
}
/**
* A single block in a preview workflow. Presentational shape — authored by hand
* for docs diagrams, not the app's full serialized block state.
*/
export interface PreviewBlock {
id: string
name: string
type: string
bgColor: string
rows: Array<{ title: string; value: string }>
/**
* Branch rows, each with its own right-edge source handle whose id is the
* branch id. Author ids in the app's own handle scheme so edges match the
* real workflow representation verbatim: `condition-<id>` on Condition
* blocks, `router-<routeId>` on Routers.
*/
branches?: Array<{ id: string; label: string; value?: string }>
tools?: PreviewTool[]
position: { x: number; y: number }
hideTargetHandle?: boolean
/** When set, the block renders as a Loop/Parallel container sized to hold its children. */
size?: { width: number; height: number }
/** Id of the container block this block sits inside. Its position is relative to the container. */
parentId?: string
}
/**
* A workflow rendered as a read-only, app-styled diagram in the docs.
*/
export interface PreviewWorkflow {
id: string
name: string
blocks: PreviewBlock[]
edges: Array<{ id: string; source: string; target: string; sourceHandle?: string }>
}
export const BLOCK_STAGGER = 0.12
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
const EDGE_STYLE = { stroke: 'var(--workflow-edge)', strokeWidth: 2 } as const
const EDGE_STYLE_HIGHLIGHT = { stroke: 'var(--brand-secondary)', strokeWidth: 2.5 } as const
/** Edges leaving a block's error port render red, matching the editor. */
const EDGE_STYLE_ERROR = { stroke: 'var(--text-error)', strokeWidth: 2 } as const
/** Optional emphasis: light one block or one edge and dim everything else. */
export interface HighlightOptions {
highlightBlock?: string
highlightEdge?: string
/** Ring one block (selection) without dimming the rest. */
selectedBlock?: string
}
/**
* Converts a {@link PreviewWorkflow} to React Flow nodes and edges.
*
* @param workflow - The workflow definition
* @param animate - When true, node/edge data carries stagger metadata
* @param highlight - Optional block/edge to emphasize (dims the rest)
*/
export function toReactFlowElements(
workflow: PreviewWorkflow,
animate = false,
highlight: HighlightOptions = {}
): { nodes: Node[]; edges: Edge[] } {
const { highlightBlock, highlightEdge, selectedBlock } = highlight
const hasHighlight = Boolean(highlightBlock || highlightEdge)
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
const blocksById = new Map(workflow.blocks.map((b) => [b.id, b]))
const nodes: Node[] = workflow.blocks.map((block, index) => {
const isContainer = Boolean(block.size)
// Nested blocks are authored relative to their container; render them at
// absolute coordinates (not React Flow parentNode children) so the edges
// between a container and its nested blocks render reliably and on top.
const parent = block.parentId ? blocksById.get(block.parentId) : undefined
const position = parent
? { x: parent.position.x + block.position.x, y: parent.position.y + block.position.y }
: block.position
return {
id: block.id,
type: isContainer ? 'previewContainer' : 'previewBlock',
position,
zIndex: isContainer ? 0 : 1,
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
data: {
name: block.name,
blockType: block.type,
bgColor: block.bgColor,
rows: block.rows,
branches: block.branches,
tools: block.tools,
hideTargetHandle: block.hideTargetHandle,
size: block.size,
index,
animate,
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
isDimmed: hasHighlight && highlightBlock !== block.id,
},
draggable: true,
selectable: false,
connectable: false,
sourcePosition: Position.Right,
targetPosition: Position.Left,
}
})
const edges: Edge[] = workflow.edges.map((e) => {
const sourceIndex = blockIndexMap.get(e.source) ?? 0
const isEdgeHighlight = highlightEdge === e.id
const dimmed = hasHighlight && !isEdgeHighlight
const isErrorEdge = e.sourceHandle === 'error'
// Subflow containers expose a right-edge output handle (`loop-end-source` /
// `parallel-end-source`) and a left-edge input handle with no id; regular
// blocks use `source` / `target`. Resolve each end to the block's real handle
// so edges into and out of Loop/Parallel containers still connect.
const sourceBlock = blocksById.get(e.source)
const targetBlock = blocksById.get(e.target)
const sourceHandle =
e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source')
const targetHandle = targetBlock?.size ? undefined : 'target'
return {
id: e.id,
source: e.source,
target: e.target,
type: 'previewEdge',
animated: false,
style: {
...(isEdgeHighlight ? EDGE_STYLE_HIGHLIGHT : isErrorEdge ? EDGE_STYLE_ERROR : EDGE_STYLE),
opacity: dimmed ? 0.35 : 1,
},
sourceHandle,
targetHandle,
data: {
animate,
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
},
}
})
return { nodes, edges }
}
@@ -0,0 +1,367 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { Maximize2, X } from 'lucide-react'
import ReactFlow, {
applyEdgeChanges,
applyNodeChanges,
type Edge,
type EdgeProps,
type EdgeTypes,
getSmoothStepPath,
type Node,
type NodeTypes,
type OnEdgesChange,
type OnNodesChange,
ReactFlowProvider,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
import { BlockInspector } from '@/components/workflow-preview/block-inspector'
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
import { DocsContainerNode } from '@/components/workflow-preview/docs-container-node'
import {
EASE_OUT,
type PreviewBlock,
type PreviewWorkflow,
toReactFlowElements,
} from '@/components/workflow-preview/workflow-data'
interface WorkflowPreviewProps {
workflow: PreviewWorkflow
/** Canvas height in px. Default 260. */
height?: number
animate?: boolean
/** Emphasize one block by id, dimming the rest. */
highlightBlock?: string
/** Emphasize one edge by id, dimming the rest. */
highlightEdge?: string
}
/** Smooth-step edge, matching the app's connection styling. */
function PreviewEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
data,
}: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 8,
offset: 30,
})
if (data?.animate) {
return (
<m.path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
opacity: { duration: 0.15, delay: data.delay ?? 0 },
}}
/>
)
}
return (
<path
id={id}
className='react-flow__edge-path'
d={edgePath}
style={{ ...style, fill: 'none' }}
/>
)
}
const NODE_TYPES: NodeTypes = {
previewBlock: DocsBlockNode,
previewContainer: DocsContainerNode,
}
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
const PRO_OPTIONS = { hideAttribution: true }
const FIT_VIEW_OPTIONS = { padding: 0.25, maxZoom: 1 } as const
const LIGHTBOX_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1.4 } as const
/** Field titles rendered as multiline text in the inspector. */
const TEXTAREA_TITLES = new Set(['Messages', 'Prompt', 'Code', 'Data', 'Body', 'Display'])
/** Field titles rendered as dropdowns in the inspector. */
const SELECT_TITLES = new Set([
'Model',
'Operation',
'Method',
'Unit',
'Event type',
'Validation',
'Account',
'Table',
'Knowledge Base',
'Language',
'Workflow',
'Format',
])
function inspectorFieldsFor(block: PreviewBlock) {
// Show the block type's full field list (from the reference data) with this
// block's example values overlaid, so the inspector reads like the editor's
// panel — every field — not just the summary rows shown on the canvas node.
// Apply the template only when the block authored rows that are actually a
// subset of it; otherwise the type string is ambiguous (a `table` action vs
// the table trigger, a `webhook` trigger vs the webhook action) and the wrong
// template would win, so the block's own authored rows are the source of
// truth. A block with no rows (e.g. a router defined only by its branches)
// keeps its empty set rather than inheriting the template's invented defaults.
const exampleByTitle = new Map(block.rows.map((row) => [row.title, row.value]))
const template = BLOCK_DISPLAY_WORKFLOWS[block.type]?.blocks[0]?.rows
const fullRows =
template &&
block.rows.length > 0 &&
block.rows.every((row) => template.some((field) => field.title === row.title))
? template
: block.rows
const rowFields = fullRows.map((row) => {
const value = exampleByTitle.get(row.title) ?? row.value
return {
label: row.title,
kind:
TEXTAREA_TITLES.has(row.title) || value.length > 40
? ('textarea' as const)
: SELECT_TITLES.has(row.title)
? ('select' as const)
: ('input' as const),
value,
}
})
const branchFields = (block.branches ?? []).map((branch) => ({
label: branch.label,
kind: 'code' as const,
// Match the canvas + the editor's getDisplayValue: a blank value reads as '-'.
value: branch.value || '-',
}))
return [...rowFields, ...branchFields]
}
function PreviewFlow({
workflow,
animate = false,
highlightBlock,
highlightEdge,
selectedBlock,
interactive = false,
onNodeClick,
onPaneClick,
}: WorkflowPreviewProps & {
selectedBlock?: string
interactive?: boolean
onNodeClick?: (blockId: string) => void
onPaneClick?: () => void
}) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => toReactFlowElements(workflow, animate, { highlightBlock, highlightEdge, selectedBlock }),
[workflow, animate, highlightBlock, highlightEdge, selectedBlock]
)
const [nodes, setNodes] = useState<Node[]>(initialNodes)
const [edges, setEdges] = useState<Edge[]>(initialEdges)
/**
* Apply data changes (highlight/selection) without discarding positions the
* viewer has dragged — only a different workflow should relayout the canvas.
*/
useEffect(() => {
setNodes((prev) => {
const positions = new Map(prev.map((node) => [node.id, node.position]))
return initialNodes.map((node) => {
const position = positions.get(node.id)
return position ? { ...node, position } : node
})
})
setEdges(initialEdges)
}, [initialNodes, initialEdges])
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
)
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
)
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick ? (_, node) => onNodeClick(node.id) : undefined}
onPaneClick={onPaneClick}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{ type: 'previewEdge' }}
elementsSelectable={false}
nodesDraggable
nodesConnectable={false}
zoomOnScroll={interactive}
zoomOnDoubleClick={interactive}
panOnScroll={false}
zoomOnPinch
panOnDrag
preventScrolling={interactive}
autoPanOnNodeDrag={false}
proOptions={PRO_OPTIONS}
minZoom={0.1}
fitView
fitViewOptions={interactive ? LIGHTBOX_FIT_VIEW_OPTIONS : FIT_VIEW_OPTIONS}
className='h-full w-full'
/>
)
}
/**
* Read-only, app-styled workflow diagram for docs pages. Renders a
* {@link PreviewWorkflow} with ReactFlow — draggable, non-editable, no app
* runtime. Clicking a block (or the expand control) opens a full-screen
* lightbox with zoom and pan, plus a read-only inspector panel showing the
* selected block's full configuration — canvas rows truncate, the inspector
* doesn't.
*
* @example
* <WorkflowPreview workflow={CLASSIFY_WORKFLOW} />
*/
export function WorkflowPreview({
workflow,
height = 340,
animate = false,
highlightBlock,
highlightEdge,
}: WorkflowPreviewProps) {
const [expanded, setExpanded] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
useEffect(() => {
if (!expanded) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setExpanded(false)
}
document.addEventListener('keydown', onKey)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.body.classList.add('wp-lightbox-open')
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = previousOverflow
document.body.classList.remove('wp-lightbox-open')
}
}, [expanded])
const selectedBlock = selectedId
? (workflow.blocks.find((b) => b.id === selectedId) ?? null)
: null
const openWith = (blockId: string | null) => {
setSelectedId(blockId)
setExpanded(true)
}
return (
<LazyMotion features={domAnimation}>
<div
className='not-prose group relative my-6 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
style={{ height }}
>
<ReactFlowProvider key={`${workflow.id}-${highlightBlock ?? ''}-${highlightEdge ?? ''}`}>
<PreviewFlow
workflow={workflow}
animate={animate}
highlightBlock={highlightBlock}
highlightEdge={highlightEdge}
onNodeClick={(id) => openWith(id)}
onPaneClick={() => openWith(null)}
/>
</ReactFlowProvider>
<button
type='button'
aria-label='Expand workflow preview'
onClick={() => openWith(null)}
className='absolute top-2 right-2 z-10 flex size-[28px] items-center justify-center rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-muted)] opacity-0 transition-opacity duration-150 hover:text-[var(--text-primary)] group-hover:opacity-100'
>
<Maximize2 className='size-[13px]' />
</button>
</div>
{expanded && (
<div
className='fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm'
onClick={() => setExpanded(false)}
onKeyDown={() => {}}
role='presentation'
>
<div
className='relative flex h-[86vh] w-[92vw] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)]'
onClick={(e) => e.stopPropagation()}
onKeyDown={() => {}}
role='presentation'
>
<div className='relative min-w-0 flex-1'>
<div className='pointer-events-none absolute top-0 right-0 left-0 z-10 flex items-center justify-between px-4 py-3'>
<span className='text-[13px] text-[var(--text-muted)]'>{workflow.name}</span>
<button
type='button'
aria-label='Close'
onClick={() => setExpanded(false)}
className='pointer-events-auto flex size-[28px] items-center justify-center rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</button>
</div>
<ReactFlowProvider key={`${workflow.id}-lightbox`}>
<PreviewFlow
workflow={workflow}
highlightBlock={highlightBlock}
highlightEdge={highlightEdge}
selectedBlock={selectedId ?? undefined}
interactive
onNodeClick={(id) => setSelectedId(id)}
onPaneClick={() => setSelectedId(null)}
/>
</ReactFlowProvider>
</div>
<div className='w-[340px] flex-shrink-0 border-[var(--border)] border-l'>
{selectedBlock ? (
<BlockInspector
embedded
name={selectedBlock.name}
type={selectedBlock.type}
color={selectedBlock.bgColor}
fields={inspectorFieldsFor(selectedBlock)}
tools={selectedBlock.tools}
/>
) : (
<div className='flex h-full items-center justify-center px-6 text-center text-[13px] text-[var(--text-muted)]'>
Select a block to see its full configuration
</div>
)}
</div>
</div>
</div>
)}
</LazyMotion>
)
}