mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
feat(docs): render workflow previews with the shared editor renderer (#5277)
* chore(workflow-renderer): declare @sim/emcn dep + wire the package into docs
Adds the missing @sim/emcn peer/dev dependency to @sim/workflow-renderer (it imports @sim/emcn in every View but resolved only via workspace hoisting). Wires apps/docs to consume @sim/workflow-renderer (dependency, transpilePackages, Tailwind @source) and adds remark-breaks (pulled transitively via the barrel's NoteBlockView export) — mirroring the @sim/emcn integration. Foundation for migrating the docs workflow-preview fork onto the shared Views. Build resolves the package/@source/remark-breaks cleanly.
* feat(docs): render loop/parallel containers with the shared SubflowNodeView
Replaces the forked PreviewContainerNode with a thin DocsContainerNode that maps the static preview data to SubflowNodeView's read-only (isPreview) props — no stores or hooks. Adds the block size to the preview node data so the view can size itself, and corrects the parallel example's start-edge handle id to 'parallel-start-source' (the view derives the handle id from kind). Deletes preview-container-node.tsx. Container colors/icons are now owned by the shared view (loop=blue, parallel=yellow).
* feat(docs): render block nodes with the shared WorkflowBlockView
Replaces the forked PreviewBlockNode with a thin DocsBlockNode that maps the static preview data to WorkflowBlockView's props — store-free, builds the subblock rows (condition/router Context+routes/default + tools + error) via SubBlockRowView, strips branch-id prefixes so the view's regenerated handle ids match, remaps router->router_v2, and keeps the framer-motion dim/stagger wrapper. Promotes resolveIcon into block-icons.tsx, adds the --workflow-edge token to docs global.css, deletes preview-block-node.tsx. The canvas diagrams now render with the real editor's view.
* refactor(workflow-renderer): make editor-only WorkflowBlockView props optional
The child-deploy, schedule, and webhook badge props (and their callbacks) only matter in the editor. Mark them optional and optional-chain the three callbacks so read-only consumers (docs, academy) can omit the whole group instead of passing ~18 explicit off-values. The editor still passes them, so its behavior is byte-identical (verified: apps/sim type-check clean). DocsBlockNode drops the off-props.
* feat(docs): replace how-it-runs static diagrams with live WorkflowPreview
Swaps the four static PNGs on the how-it-runs page for live, app-styled WorkflowPreview diagrams (concurrency, combination, condition+router branching, error path). Adds the four example workflows and renders error-port edges red to match the editor. The English page only; the translated execution/basics pages keep the PNGs.
* refactor(workflow-renderer): the view owns condition/router/error rows
Both the editor container and the docs adapter hand-built the condition/router/error summary rows in an order that had to stay in lockstep with the view's absolute handle-offset math — a three-way coupling with nothing enforcing it. The view now renders those rows itself from the conditionRows/routerRows it already receives (plus a routerContextValue prop for the router's Context row), so row order and handle geometry live together in one place. Both containers pass only data and their non-branch rows.
Editor is byte-identical: getDisplayValue moves to where conditionRows/routerRows are built; the no-subBlock SubBlockRow path is already an exact SubBlockRowView(title, value) passthrough; the error row stays gated on shouldShowDefaultHandles. Verified apps/sim type-check clean. Docs now also renders the error row on condition/router blocks, which the real editor already did (shouldShowDefaultHandles is true for them) — an alignment fix.
* refactor(docs): drop the parallel --wp-* token layer for the app/emcn tokens
The workflow-preview ran a 25-token --wp-* mirror (22 were pure aliases of app tokens docs already defines) plus a .wp-scope wrapper class. Replaces every var(--wp-X) with its canonical app/emcn token (--wp-edge->--workflow-edge, --wp-highlight->--brand-secondary, badges->--badge-*, etc.), adds the one missing token (--divider), and deletes the .wp-scope blocks + class. Visually identical (aliases resolve to the same values); the preview now inherits the same design tokens as the shared views and the rest of the app instead of a hand-rolled parallel set.
* refactor(docs): adopt emcn Badge + dedup resolveIcon in workflow-preview
output-bundle's hand-rolled type badge (BADGE_COLORS + a styled span) becomes the emcn Badge (its green/blue/orange/purple/gray variants use the identical --badge-* tokens). resolveIcon, which had three copies, is now imported once from block-icons by output-bundle and block-inspector.
* refactor(docs): rebuild the preview inspector on emcn chip primitives
The lightbox inspector was a hand-rolled facsimile (raw divs + a CONTROL class string + inline dashed borders). It now composes from the same @sim/emcn primitives the live editor's sub-block controls wrap — ChipSelect/ChipInput/ChipTextarea(viewOnly)/ChipSwitch/ChipTag/FieldDivider/Label — so it reads as the real editor panel, fed example data (read-only, full opacity via readOnly/viewOnly, not greyed). Slider stays minimal (no emcn equivalent) but on app tokens. Props API and embedded/standalone modes unchanged.
* refactor(docs): render the block-reference hero through the shared View
Retires the hand-rolled BlockCard (a parallel reimplementation of WorkflowBlockView) and the BlockDisplaySpec data model. Each block hero is now a single-block PreviewWorkflow (block-display-workflows.ts) rendered through the same toReactFlowElements -> DocsBlockNode -> WorkflowBlockView pipeline as the diagrams, mounted in a minimal fitView ReactFlow (maxZoom 1.3, no canvas chrome). A single block can no longer drift from the canvas.
* fix(docs): define sim's type scale + align the preview inspector to the editor
Docs Tailwind v4 never defined sim's custom font sizes (text-small/caption/md/micro), so emcn components (Label, Badge, the shared views) fell back to inherited sizes — the inspector labels rendered huge. Adds the type scale to the docs @theme. Also aligns the inspector header to the real editor panel (surface-4 bar, size-[18px] rounded-sm icon, text-sm name) and removes the Connections section (and its now-dead prop/wiring).
* fix(docs): inspector shows the full field list + dragged positions persist
Inspector: shows the block type's full field list (from the reference data) with the example's values overlaid, so it reads like the editor panel instead of only the canvas summary rows. Drag: selecting another block no longer relayouts the canvas — node positions the viewer dragged are preserved across highlight/selection changes (only a different workflow relayouts).
* feat(docs): highlight <> references + env vars; hide Ask AI over the lightbox; respace blocks
Inspector text fields render the value with <...> block references and {{...}} environment variables highlighted in brand-secondary (a lean read-only port of the editor's formatDisplayText), in the canonical chip field chrome. The floating Ask AI widget is hidden while a preview lightbox is open. Plus the example-data respacing so the editor-faithful Error row no longer makes stacked blocks overlap.
* fix(docs): make per-type field templates match the real block registry
Audited every block type's field list (the source the inspector + block-reference heroes render) against apps/sim/blocks/blocks/*. Corrected drift to the registry's default-visible fields, titles, and order: agent gains Temperature; router gains Model; wait gains Async; schedule rewritten (default is Daily, not minutes); webhook_trigger expanded to its real default-visible set; human_in_the_loop notification title fixed. Provider-credential and advanced-mode fields stay hidden, matching the editor. Canvas diagrams keep their clean curated rows; the inspector now shows the full, real field list per the chosen clean-canvas/full-inspector split.
* improvement(docs): taller default preview height so respaced diagrams aren't shrunk
Bumps the default WorkflowPreview height 260->300 (the respaced, editor-faithful blocks are taller, so fitView was shrinking diagrams that relied on the default). The tall how-it-runs routing diagram gets 400.
* improvement(docs): zoomable inline preview + taller default + themed controls
The inline preview is now zoomable outside the lightbox: adds react-flow zoom/fit Controls (themed to the dark canvas chrome) and enables pinch-zoom, while keeping scroll-zoom off so the page still scrolls over the diagram. Pan-drag and click-block-to-inspect already worked. Default height 300->340.
* improvement(docs): click canvas to expand; click empty lightbox to deselect
Clicking the inline preview canvas opens the full lightbox; clicking empty space in the lightbox clears the selection, matching the real editor.
* improvement(docs): reveal inline zoom controls on hover only
The always-visible zoom controls felt heavy on the inline preview; they now fade in on hover (matching the expand button) and stay visible in the lightbox.
* improvement(docs): drop zoom controls on the inline preview
Inline preview keeps pinch-zoom, pan, drag, and click-to-expand; zoom buttons stay in the lightbox only.
* improvement(docs): remove zoom controls from the lightbox too
Both previews zoom via scroll/pinch and pan via drag; no on-canvas zoom buttons. Drops the Controls import and its theming CSS.
* improvement(docs): match the real canvas — flat background + editor edge geometry
Closes the last faithfulness gaps the audit found: removes the dot grid (the real editor hides its background — flat bg), aligns PreviewEdge to the editor's smoothstep math (borderRadius 8, offset 30) and 2px stroke (default + error edges), the selection ring to 1.75px, and minZoom to 0.1. Structural parity (blocks/handles/containers/colors/tokens) was already shared. Kept PreviewEdge rather than swapping to WorkflowEdgeView, which would clobber the docs-only highlight/dim/animate for no visual gain.
* improvement(docs): rebrand the docs assistant as 'Ask Sim', styled like the real chat input
Renames the floating assistant from 'Ask AI' to 'Ask Sim' (matching the platform's voice — you talk to Sim) and restyles the composer to mirror the home chat input: a rounded-2xl bordered field with the toolbar inside, and the same 28px circular send/stop button (the home's exact active/disabled colors + white/black arrow). Updates the lightbox hide-selector to the new label.
* improvement(docs): match Ask Sim message styling to the mothership chat
Aligns the user bubble (rounded-[16px] surface-5, text-base/primary, leading-23, max-w-85%) and the assistant markdown (text-base, 600 headings/strong, text-primary dashed-underline links, surface-5 code blocks) to the real mothership chat's user-message + chat-content treatment, instead of the prior generic text-sm rendering. The composer already mirrors the home user-input (rounded-2xl field + 28px circular send button).
* improvement(docs): compact single-row Ask Sim composer
The two-row layout left a tall dead gap (the docs widget has no toolbar buttons to fill the second row). The composer is now a single row — textarea with the circular send button inline — so it sits at the natural input height.
* fix(docs): pass the router Context value to the shared view
DocsBlockNode never set routerContextValue, so the view (which renders the router's Context row from that prop, not from rows) showed a blank Context even when the preview data authored a value like <start.input>. Extract it from the block's Context row and pass it through.
* fix(docs): don't apply a block-type field template that doesn't match the block
inspectorFieldsFor keyed the full field template purely off block.type, but some types are reused across roles (a table action block vs the table trigger, a webhook trigger vs the webhook action), so the wrong template was applied. Only use the template when the block's authored rows are actually a subset of it; otherwise fall back to the block's own rows.
* fix(docs): connect preview edges to subflow container handles
toReactFlowElements hardcoded targetHandle to 'target' and defaulted source handles to 'source', but Loop/Parallel containers (SubflowNodeView) expose a 'loop-end-source'/'parallel-end-source' output handle and a left input handle with no id. Edges into and out of containers therefore failed to connect. Resolve each edge end to the block's real handle based on whether it's a container.
* fix(docs): don't expand the inspector template for blocks with no rows
block.rows.every(...) is vacuously true for an empty rows array, so a block defined only by branches (e.g. a router in ROUTING_WORKFLOW) inherited the type template's invented field defaults. Require non-empty authored rows before applying the template.
* fix(docs): render blank branch/router-context values as '-' like the editor
The editor maps condition/router branch values and the router Context through getDisplayValue, which renders '-' for a blank value. DocsBlockNode mapped them to an empty string, so else branches and unset routes looked blank instead of matching the editor. Mirror getDisplayValue's empty-value handling.
* fix(docs): show '-' for blank inspector branch values, matching the canvas
inspectorFieldsFor passed raw branch.value into the lightbox branch fields, so an unset else route read blank in the inspector while DocsBlockNode (and the editor's getDisplayValue) render '-' on the canvas. Normalize the same way; drop the now-redundant placeholder.
This commit is contained in:
+19
-50
@@ -3,6 +3,7 @@
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
@import "fumadocs-openapi/css/preset.css";
|
||||
@source "../../../packages/emcn/src";
|
||||
@source "../../../packages/workflow-renderer/src";
|
||||
|
||||
/* Prevent overscroll bounce effect on the page */
|
||||
html,
|
||||
@@ -19,6 +20,20 @@ body {
|
||||
|
||||
@theme {
|
||||
--color-fd-primary: var(--color-fd-foreground);
|
||||
/* Sim's custom type scale — emcn components (Label, Badge, the shared block
|
||||
views) use these names, so they must resolve here or text falls back to the
|
||||
inherited size. Mirrors apps/sim/tailwind.config.ts. */
|
||||
--text-micro: 10px;
|
||||
--text-xs: 11px;
|
||||
--text-caption: 12px;
|
||||
--text-small: 13px;
|
||||
--text-base: 15px;
|
||||
--text-md: 16px;
|
||||
}
|
||||
|
||||
/* Hide the floating Ask Sim widget while a workflow-preview lightbox is open. */
|
||||
body.wp-lightbox-open [aria-label="Ask Sim"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Pure white light mode background */
|
||||
@@ -42,6 +57,7 @@ body {
|
||||
*/
|
||||
:root {
|
||||
--bg: #fefefe;
|
||||
--workflow-edge: #e0e0e0;
|
||||
--surface-1: #fbfbfb;
|
||||
--surface-2: #ffffff;
|
||||
--surface-3: #f7f7f7;
|
||||
@@ -51,6 +67,7 @@ body {
|
||||
--surface-active: #ececec;
|
||||
--border: #dedede;
|
||||
--border-1: #e0e0e0;
|
||||
--divider: #ededed;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #525252;
|
||||
--text-tertiary: #5c5c5c;
|
||||
@@ -91,6 +108,7 @@ body {
|
||||
|
||||
.dark {
|
||||
--bg: #1b1b1b;
|
||||
--workflow-edge: #454545;
|
||||
--surface-1: #1e1e1e;
|
||||
--surface-2: #232323;
|
||||
--surface-3: #242424;
|
||||
@@ -100,6 +118,7 @@ body {
|
||||
--surface-active: #2c2c2c;
|
||||
--border: #333333;
|
||||
--border-1: #3d3d3d;
|
||||
--divider: #393939;
|
||||
--text-primary: #e6e6e6;
|
||||
--text-secondary: #cccccc;
|
||||
--text-tertiary: #b3b3b3;
|
||||
@@ -1661,56 +1680,6 @@ main article blockquote {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Workflow-preview theme scope. Values mirror the app's tokens in
|
||||
apps/sim/app/_styles/globals.css (light from :root/.light, dark from .dark)
|
||||
so the docs previews match the OG repository in both modes. */
|
||||
.wp-scope {
|
||||
/* surfaces */
|
||||
--wp-canvas: var(--bg);
|
||||
--wp-panel: var(--surface-1);
|
||||
--wp-surface: var(--surface-2);
|
||||
--wp-header: var(--surface-3);
|
||||
--wp-btn: var(--surface-4);
|
||||
--wp-control: var(--surface-5);
|
||||
--wp-active: var(--surface-active);
|
||||
--wp-container-fill: rgba(0, 0, 0, 0.02);
|
||||
|
||||
/* borders */
|
||||
--wp-border: var(--border);
|
||||
--wp-border-1: var(--border-1);
|
||||
--wp-chip-bg: var(--surface-5); /* ChipTag surface (light) */
|
||||
--wp-chip-text: var(--text-body);
|
||||
--wp-divider: #ededed; /* --divider */
|
||||
--wp-edge: #e0e0e0; /* --workflow-edge */
|
||||
--wp-highlight: #33b4ff; /* traced edge / highlighted output node */
|
||||
|
||||
/* text */
|
||||
--wp-text: var(--text-primary);
|
||||
--wp-text-2: var(--text-secondary);
|
||||
--wp-text-3: var(--text-tertiary);
|
||||
--wp-text-muted: var(--text-muted);
|
||||
--wp-text-subtle: var(--text-subtle);
|
||||
|
||||
/* type badges (output inspector) */
|
||||
--wp-badge-success-bg: var(--badge-success-bg);
|
||||
--wp-badge-success-text: var(--badge-success-text);
|
||||
--wp-badge-blue-bg: var(--badge-blue-bg);
|
||||
--wp-badge-blue-text: var(--badge-blue-text);
|
||||
--wp-badge-orange-bg: var(--badge-orange-bg);
|
||||
--wp-badge-orange-text: var(--badge-orange-text);
|
||||
--wp-badge-purple-bg: var(--badge-purple-bg);
|
||||
--wp-badge-purple-text: var(--badge-purple-text);
|
||||
--wp-badge-gray-bg: var(--badge-gray-bg);
|
||||
--wp-badge-gray-text: var(--badge-gray-text);
|
||||
}
|
||||
|
||||
.dark .wp-scope {
|
||||
--wp-container-fill: rgba(255, 255, 255, 0.02);
|
||||
--wp-chip-bg: var(--surface-4); /* ChipTag surface (dark) */
|
||||
--wp-divider: #393939;
|
||||
--wp-edge: #454545;
|
||||
}
|
||||
|
||||
/* Tailwind v4 content sources */
|
||||
@source '../app/**/*.{js,ts,jsx,tsx,mdx}';
|
||||
@source '../components/**/*.{js,ts,jsx,tsx,mdx}';
|
||||
|
||||
@@ -82,12 +82,12 @@ export function AskAI({ locale }: AskAIProps) {
|
||||
{!open && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Ask AI'
|
||||
aria-label='Ask Sim'
|
||||
onClick={() => setOpen(true)}
|
||||
className='fixed right-4 bottom-4 z-50 flex h-11 items-center gap-1.5 rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] px-4 font-season text-[var(--text-body)] text-sm shadow-[var(--shadow-medium)] transition-colors hover:bg-[var(--surface-active)] dark:bg-[var(--surface-4)]'
|
||||
>
|
||||
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
|
||||
Ask AI
|
||||
Ask Sim
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AskAI({ locale }: AskAIProps) {
|
||||
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
|
||||
<span className='flex items-center gap-1.5 font-season text-[var(--text-body)] text-sm'>
|
||||
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
|
||||
Ask AI
|
||||
Ask Sim
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
@@ -127,18 +127,27 @@ export function AskAI({ locale }: AskAIProps) {
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex flex-col gap-1',
|
||||
'flex flex-col gap-1.5',
|
||||
message.role === 'user' ? 'items-end' : 'items-start'
|
||||
)}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<div className='max-w-[90%] whitespace-pre-wrap rounded-lg bg-[var(--surface-active)] px-3 py-2 text-[var(--text-body)] text-sm'>
|
||||
<div className='max-w-[85%] whitespace-pre-wrap rounded-[16px] bg-[var(--surface-5)] px-3 py-2 text-[var(--text-primary)] text-base leading-[23px]'>
|
||||
{text}
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-w-[90%] text-[var(--text-body)] text-sm'>
|
||||
<div className='max-w-full text-[var(--text-primary)] text-base'>
|
||||
{text ? (
|
||||
<Streamdown className='space-y-2 text-sm leading-relaxed [&_a]:text-[var(--brand-accent)] [&_a]:underline [&_li]:my-0.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5'>
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'space-y-3 text-[var(--text-primary)] text-base leading-relaxed',
|
||||
'[&_a]:text-[var(--text-primary)] [&_a]:underline [&_a]:decoration-dashed [&_a]:underline-offset-4',
|
||||
'[&_strong]:font-[600]',
|
||||
'[&_h1]:font-[600] [&_h2]:font-[600] [&_h3]:font-[600] [&_h4]:font-[600]',
|
||||
'[&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5',
|
||||
'[&_code]:font-mono [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-[var(--surface-5)] [&_pre]:p-3 [&_pre]:text-small'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Streamdown>
|
||||
) : isStreaming ? (
|
||||
@@ -174,42 +183,46 @@ export function AskAI({ locale }: AskAIProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className='flex items-end gap-2 border-[var(--border-1)] border-t px-3 py-3'
|
||||
>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit(event)
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder='Ask a question…'
|
||||
className='max-h-32 flex-1 resize-none bg-transparent font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
{isBusy ? (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Stop'
|
||||
onClick={() => stop()}
|
||||
className='flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-active)] text-[var(--text-icon)]'
|
||||
>
|
||||
<Square className='size-[16px]' />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type='submit'
|
||||
aria-label='Send'
|
||||
disabled={!input.trim()}
|
||||
className='flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-opacity disabled:opacity-40 dark:bg-white dark:text-[var(--bg)]'
|
||||
>
|
||||
<ArrowUp className='size-[16px]' />
|
||||
</button>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className='px-3 pb-3'>
|
||||
<div className='flex items-end gap-2 rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-1.5 dark:bg-[var(--surface-5)]'>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit(event)
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder='Ask Sim about the docs…'
|
||||
className='max-h-32 flex-1 resize-none bg-transparent py-1 font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
{isBusy ? (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Stop'
|
||||
onClick={() => stop()}
|
||||
className='flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-colors hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
|
||||
>
|
||||
<Square className='size-[12px] fill-white text-white dark:fill-black dark:text-black' />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type='submit'
|
||||
aria-label='Send'
|
||||
disabled={!input.trim()}
|
||||
className={cn(
|
||||
'flex size-[28px] shrink-0 items-center justify-center rounded-full transition-colors',
|
||||
input.trim()
|
||||
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
|
||||
: 'bg-[#808080]'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className='size-[16px] text-white dark:text-black' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* A hand-authored block, described as what the builder canvas displays.
|
||||
*
|
||||
* - `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); when set, also set `hideSourceHandle: true` so the single output is replaced.
|
||||
* - `showError: true` adds the bottom `Error` row + red handle (action blocks, not triggers).
|
||||
* - `hideTargetHandle: true` for triggers (entry points — no input).
|
||||
* - `bgColor` is the resolved hex; the Agent uses Sim green `#33C482` (`var(--brand)`).
|
||||
*/
|
||||
export interface BlockDisplaySpec {
|
||||
name: string
|
||||
/** Block type — drives the header icon (see `BLOCK_ICONS`). */
|
||||
type: string
|
||||
/** Resolved brand color (hex). */
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
branches?: string[]
|
||||
showError?: boolean
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Display specs for the block reference previews — one per block, edited to match exactly
|
||||
* what the builder canvas shows. Source of truth for the `<BlockPreview>` heroes.
|
||||
*/
|
||||
|
||||
export const BLOCK_DISPLAY_SPECS: Record<string, BlockDisplaySpec> = {
|
||||
agent: {
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
showError: true,
|
||||
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: 'Response Format', value: '-' },
|
||||
],
|
||||
},
|
||||
api: {
|
||||
name: 'API',
|
||||
type: 'api',
|
||||
bgColor: '#2F55FF',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'URL', value: '-' },
|
||||
{ title: 'Method', value: 'GET' },
|
||||
{ title: 'Query Params', value: '-' },
|
||||
{ title: 'Headers', value: '-' },
|
||||
{ title: 'Body', value: '-' },
|
||||
],
|
||||
},
|
||||
condition: {
|
||||
name: 'Condition',
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
showError: true,
|
||||
hideSourceHandle: true,
|
||||
rows: [],
|
||||
branches: ['if', 'else if', 'else'],
|
||||
},
|
||||
credential: {
|
||||
name: 'Credential',
|
||||
type: 'credential',
|
||||
bgColor: '#6366F1',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Select Credential' },
|
||||
{ title: 'Credential', value: '-' },
|
||||
],
|
||||
},
|
||||
evaluator: {
|
||||
name: 'Evaluator',
|
||||
type: 'evaluator',
|
||||
bgColor: '#4D5FFF',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Evaluation Metrics', value: '-' },
|
||||
{ title: 'Content', value: '-' },
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
],
|
||||
},
|
||||
function: {
|
||||
name: 'Function',
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Language', value: 'JavaScript' },
|
||||
{ title: 'Code', value: '-' },
|
||||
],
|
||||
},
|
||||
guardrails: {
|
||||
name: 'Guardrails',
|
||||
type: 'guardrails',
|
||||
bgColor: '#3D642D',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Content to Validate', value: '-' },
|
||||
{ title: 'Validation Type', value: 'Valid JSON' },
|
||||
],
|
||||
},
|
||||
response: {
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
showError: true,
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Response Data Mode', value: 'Builder' },
|
||||
{ title: 'Response Structure', value: '-' },
|
||||
{ title: 'Status Code', value: '-' },
|
||||
{ title: 'Response Headers', value: '-' },
|
||||
],
|
||||
},
|
||||
router: {
|
||||
name: 'Router',
|
||||
type: 'router',
|
||||
bgColor: '#28C43F',
|
||||
showError: true,
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Context', value: '-' }],
|
||||
branches: ['route 1'],
|
||||
},
|
||||
variables: {
|
||||
name: 'Variables',
|
||||
type: 'variables',
|
||||
bgColor: '#8B5CF6',
|
||||
showError: true,
|
||||
rows: [{ title: 'Variable Assignments', value: '-' }],
|
||||
},
|
||||
wait: {
|
||||
name: 'Wait',
|
||||
type: 'wait',
|
||||
bgColor: '#F59E0B',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Wait Amount', value: '-' },
|
||||
{ title: 'Unit', value: 'Seconds' },
|
||||
],
|
||||
},
|
||||
webhook: {
|
||||
name: 'Webhook',
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Webhook URL', value: '-' },
|
||||
{ title: 'Payload', value: '-' },
|
||||
{ title: 'Signing Secret', value: '-' },
|
||||
{ title: 'Additional Headers', value: '-' },
|
||||
],
|
||||
},
|
||||
workflow: {
|
||||
name: 'Workflow',
|
||||
type: 'workflow',
|
||||
bgColor: '#6366F1',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Select Workflow', value: '-' },
|
||||
{ title: 'Input Variable', value: '-' },
|
||||
],
|
||||
},
|
||||
human_in_the_loop: {
|
||||
name: 'Human in the Loop',
|
||||
type: 'human_in_the_loop',
|
||||
bgColor: '#10B981',
|
||||
showError: true,
|
||||
rows: [
|
||||
{ title: 'Display Data', value: '-' },
|
||||
{ title: 'Notification', value: '-' },
|
||||
{ title: 'Resume Form', value: '-' },
|
||||
],
|
||||
},
|
||||
schedule: {
|
||||
name: 'Schedule',
|
||||
type: 'schedule',
|
||||
bgColor: '#6366F1',
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Run frequency', value: 'Every X Minutes' },
|
||||
{ title: 'Interval (minutes)', value: '-' },
|
||||
],
|
||||
},
|
||||
rss: {
|
||||
name: 'RSS Feed',
|
||||
type: 'rss',
|
||||
bgColor: '#F97316',
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Feed URL', value: '-' }],
|
||||
},
|
||||
webhook_trigger: {
|
||||
name: 'Webhook',
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Webhook URL', value: '-' },
|
||||
{ title: 'Require Authentication', value: '-' },
|
||||
{ title: 'Input Format', value: '-' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
name: 'Table',
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
hideTargetHandle: true,
|
||||
rows: [
|
||||
{ title: 'Table', value: '-' },
|
||||
{ title: 'Event type', value: 'Row updated' },
|
||||
{ title: 'Watch columns', value: '-' },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SVGProps } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { Clock, Database, Layers, Repeat, Table } from 'lucide-react'
|
||||
import {
|
||||
ApiIcon,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
WebhookIcon,
|
||||
WorkflowIcon,
|
||||
} from '@/components/icons'
|
||||
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
|
||||
|
||||
/**
|
||||
* The two Sim-specific block glyphs we need, ported verbatim from
|
||||
@@ -101,3 +102,12 @@ export const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: strin
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { BookOpen, ChevronDown, ChevronRight, Pencil } from 'lucide-react'
|
||||
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
|
||||
import { BLOCK_ICONS } from '@/components/workflow-preview/block-icons'
|
||||
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'
|
||||
|
||||
@@ -18,12 +28,6 @@ interface InspectorField {
|
||||
percent?: number
|
||||
}
|
||||
|
||||
interface InspectorConnection {
|
||||
name: string
|
||||
type?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface InspectorTool {
|
||||
type: string
|
||||
name: string
|
||||
@@ -38,53 +42,48 @@ interface BlockInspectorProps {
|
||||
color?: string
|
||||
fields: InspectorField[]
|
||||
tools?: InspectorTool[]
|
||||
connections?: InspectorConnection[]
|
||||
/** Render as a borderless panel filling its parent (the lightbox sidebar). */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
function resolveIcon(type: string) {
|
||||
return BLOCK_ICONS[type] ?? blockTypeToIconMap[type] ?? null
|
||||
}
|
||||
|
||||
const CONTROL =
|
||||
'flex w-full items-center justify-between gap-2 rounded-[10px] bg-[var(--wp-control)] px-3 py-2.5 text-[13px]'
|
||||
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 hasValue = field.value !== undefined && field.value !== ''
|
||||
const textColor = hasValue ? 'var(--wp-text)' : 'var(--wp-text-muted)'
|
||||
const text = hasValue ? field.value : (field.placeholder ?? '—')
|
||||
const value = field.value ?? ''
|
||||
const placeholder = field.placeholder ?? '—'
|
||||
|
||||
if (kind === 'textarea' || kind === 'code') {
|
||||
if (kind === 'select') {
|
||||
return (
|
||||
<div
|
||||
className='w-full rounded-[10px] bg-[var(--wp-control)] px-3 py-2.5 text-[13px] leading-[1.55]'
|
||||
style={{
|
||||
color: textColor,
|
||||
fontFamily: kind === 'code' ? 'var(--font-mono, monospace)' : undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
<ChipSelect
|
||||
fullWidth
|
||||
value={value || undefined}
|
||||
onChange={NOOP}
|
||||
placeholder={placeholder}
|
||||
options={value ? [{ value, label: value }] : []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'toggle') {
|
||||
const on = field.value === 'on'
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className='flex h-[18px] w-[32px] items-center rounded-full px-[2px] transition-colors'
|
||||
style={{ background: on ? 'var(--brand-accent)' : 'var(--wp-border-1)' }}
|
||||
>
|
||||
<div
|
||||
className='size-[14px] rounded-full bg-white'
|
||||
style={{ marginLeft: on ? 'auto' : 0 }}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--wp-text-muted)]'>{on ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
<ChipSwitch
|
||||
value={on ? 'on' : 'off'}
|
||||
onChange={NOOP}
|
||||
aria-label={field.label}
|
||||
options={[
|
||||
{ value: 'on', label: 'On' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,37 +91,65 @@ function FieldControl({ field }: { field: InspectorField }) {
|
||||
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(--wp-border-1)]'>
|
||||
<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-accent)]'
|
||||
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 bg-white'
|
||||
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(--wp-text)]'>{field.value}</span>
|
||||
<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={CONTROL}>
|
||||
<span className='truncate' style={{ color: textColor }}>
|
||||
{text}
|
||||
</span>
|
||||
{kind === 'select' && (
|
||||
<ChevronDown className='size-[14px] flex-shrink-0 text-[var(--wp-text-muted)]' />
|
||||
)}
|
||||
<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 controls, and its connections.
|
||||
* Hand-authored per usage, like {@link WorkflowPreview} examples.
|
||||
* header, its configuration fields as static chip controls, and its
|
||||
* connections. Hand-authored per usage, like {@link WorkflowPreview} examples.
|
||||
*/
|
||||
export function BlockInspector({
|
||||
name,
|
||||
@@ -130,100 +157,69 @@ export function BlockInspector({
|
||||
color = '#33C482',
|
||||
fields,
|
||||
tools,
|
||||
connections,
|
||||
embedded = false,
|
||||
}: BlockInspectorProps) {
|
||||
const Icon = resolveIcon(type)
|
||||
const hasTools = Boolean(tools && tools.length > 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
className={cn(
|
||||
'bg-[var(--surface-1)]',
|
||||
embedded
|
||||
? 'wp-scope flex h-full w-full flex-col overflow-y-auto bg-[var(--wp-panel)]'
|
||||
: 'wp-scope not-prose my-6 w-full max-w-[380px] overflow-hidden rounded-xl border border-[var(--wp-border)] bg-[var(--wp-panel)]'
|
||||
}
|
||||
? '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 gap-2.5 border-[var(--wp-border)] border-b px-3 py-2.5'>
|
||||
<div
|
||||
className='flex size-[22px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: color }}
|
||||
>
|
||||
{Icon && <Icon className='size-[13px] text-white' />}
|
||||
<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>
|
||||
<span className='font-medium text-[14px] text-[var(--wp-text)]'>{name}</span>
|
||||
<span className='ml-auto flex items-center gap-2.5 text-[var(--wp-text-muted)]'>
|
||||
<Pencil className='size-[13px]' />
|
||||
<BookOpen className='size-[13px]' />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col px-3 py-1'>
|
||||
<div className='flex flex-col px-3 py-3'>
|
||||
{fields.map((field, i) => (
|
||||
<div
|
||||
key={field.label}
|
||||
className='flex flex-col gap-2 py-3'
|
||||
style={i > 0 ? { borderTop: '1px dashed var(--wp-divider)' } : undefined}
|
||||
>
|
||||
<span className='font-medium text-[13px] text-[var(--wp-text)]'>
|
||||
{field.label}
|
||||
{field.required && <span className='ml-1 text-[var(--wp-text-muted)]'>*</span>}
|
||||
</span>
|
||||
<FieldControl field={field} />
|
||||
<div key={field.label}>
|
||||
{i > 0 && <FieldDivider />}
|
||||
<InspectorFieldRow field={field} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tools && tools.length > 0 && (
|
||||
<div
|
||||
className='flex flex-col gap-2 py-3'
|
||||
style={fields.length > 0 ? { borderTop: '1px dashed var(--wp-divider)' } : undefined}
|
||||
>
|
||||
<span className='font-medium text-[13px] text-[var(--wp-text)]'>Tools</span>
|
||||
<div className='flex flex-wrap gap-[6px]'>
|
||||
{tools.map((tool) => {
|
||||
const TIcon = resolveIcon(tool.type)
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='flex items-center gap-[5px] rounded-[6px] border border-[var(--wp-border-1)] bg-[var(--wp-control)] px-[7px] py-[4px]'
|
||||
>
|
||||
<div
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{TIcon && <TIcon className='size-[9px] text-white' />}
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--wp-text)]'>{tool.name}</span>
|
||||
</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>
|
||||
|
||||
{connections && connections.length > 0 && (
|
||||
<div className='border-[var(--wp-border)] border-t px-3 py-2.5'>
|
||||
<div className='flex items-center gap-1.5 text-[12px] text-[var(--wp-text-muted)]'>
|
||||
<ChevronDown className='size-[12px]' />
|
||||
Connections
|
||||
</div>
|
||||
{connections.map((c) => {
|
||||
const CIcon = c.type ? resolveIcon(c.type) : null
|
||||
return (
|
||||
<div key={c.name} className='mt-2 flex items-center gap-2'>
|
||||
<div
|
||||
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-[5px]'
|
||||
style={{ background: c.color ?? 'var(--wp-border-1)' }}
|
||||
>
|
||||
{CIcon && <CIcon className='size-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='text-[13px] text-[var(--wp-text)]'>{c.name}</span>
|
||||
<ChevronRight className='size-[12px] text-[var(--wp-text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,109 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BLOCK_DISPLAY_SPECS,
|
||||
type BlockDisplaySpec,
|
||||
} from '@/components/workflow-preview/block-display-specs'
|
||||
import { BLOCK_ICONS } from '@/components/workflow-preview/block-icons'
|
||||
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'
|
||||
|
||||
/** Display scale for the hero — bump this one number to resize. */
|
||||
const SCALE = 1.3
|
||||
|
||||
const DOT = '-translate-y-1/2 absolute top-1/2 h-5 w-[7px]'
|
||||
const HEADER_DOT = '-translate-y-1/2 absolute top-[20px] h-5 w-[7px]'
|
||||
|
||||
/**
|
||||
* Static, app-styled block card — the same visual as the canvas WorkflowBlock, but
|
||||
* with non-interactive decorative handles (no ReactFlow, so it can't be panned/dragged).
|
||||
*/
|
||||
function BlockCard({ spec }: { spec: BlockDisplaySpec }) {
|
||||
const Icon = BLOCK_ICONS[spec.type]
|
||||
const branches = spec.branches ?? []
|
||||
const hasContent = spec.rows.length > 0 || branches.length > 0 || Boolean(spec.showError)
|
||||
|
||||
return (
|
||||
<div className='relative w-[250px] select-none rounded-[8px] border border-[var(--wp-border-1)] bg-[var(--wp-surface)]'>
|
||||
{!spec.hideTargetHandle && (
|
||||
<span className={`${HEADER_DOT} left-[-8px] rounded-l-[2px] bg-[var(--wp-edge)]`} />
|
||||
)}
|
||||
{!spec.hideSourceHandle && (
|
||||
<span className={`${HEADER_DOT} right-[-8px] rounded-r-[2px] bg-[var(--wp-edge)]`} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[var(--wp-border-1)] border-b' : ''}`}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: spec.bgColor }}
|
||||
>
|
||||
{Icon && <Icon className='size-[16px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[16px] text-[var(--wp-text)]'>
|
||||
{spec.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{spec.rows.map((row) => (
|
||||
<div key={row.title} className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--wp-text-3)] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end font-normal text-[14px] text-[var(--wp-text)]'>
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{branches.map((branch) => (
|
||||
<div key={branch} className='relative flex items-center justify-between gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--wp-text-3)] capitalize'>
|
||||
{branch}
|
||||
</span>
|
||||
<span className='font-normal text-[14px] text-[var(--wp-text-3)]'>-</span>
|
||||
<span className={`${DOT} right-[-16px] rounded-r-[2px] bg-[var(--wp-edge)]`} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{spec.showError && (
|
||||
<div className='relative flex items-center'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--wp-text-3)] capitalize'>
|
||||
error
|
||||
</span>
|
||||
<span className={`${DOT} right-[-16px] rounded-r-[2px] bg-[var(--text-error)]`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/** 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_SPECS} (e.g. `agent`, `condition`, `webhook_trigger`). */
|
||||
/** 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, from its
|
||||
* hand-authored display spec — static (no canvas) and scaled up. Use as the hero on a
|
||||
* block reference page: `<BlockPreview type="agent" />`. Edit specs in `block-display-specs.ts`.
|
||||
* 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 spec = BLOCK_DISPLAY_SPECS[type]
|
||||
if (!spec) return null
|
||||
const workflow = BLOCK_DISPLAY_WORKFLOWS[type]
|
||||
|
||||
const elements = useMemo(() => (workflow ? toReactFlowElements(workflow) : null), [workflow])
|
||||
|
||||
if (!workflow || !elements) return null
|
||||
|
||||
return (
|
||||
<div className='wp-scope not-prose my-6 flex justify-center overflow-x-auto rounded-xl border border-[var(--wp-border)] bg-[var(--wp-canvas)] px-6 py-10'>
|
||||
<div style={{ zoom: SCALE }}>
|
||||
<BlockCard spec={spec} />
|
||||
</div>
|
||||
<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={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -24,7 +24,6 @@ export const CLASSIFY_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 340, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Messages', value: 'Classify <start.input>' },
|
||||
@@ -69,7 +68,6 @@ export const CLASSIFY_REPLY_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 660, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Messages', value: 'Draft a reply for <classify.content>' },
|
||||
@@ -116,7 +114,6 @@ export const SUPPORT_KB_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 660, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Messages', value: 'Answer from <knowledge.results>' },
|
||||
@@ -168,7 +165,6 @@ export const TABLE_ENRICH_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 660, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Update Rows' },
|
||||
{ title: 'Set', value: 'status = qualified' },
|
||||
@@ -215,7 +211,6 @@ export const API_FETCH_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 660, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Model', value: 'claude-sonnet-4-6' },
|
||||
{ title: 'Messages', value: 'Summarize <api.data>' },
|
||||
@@ -263,7 +258,6 @@ export const CONDITION_ROUTE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Escalate this ticket' }],
|
||||
},
|
||||
{
|
||||
@@ -271,8 +265,7 @@ export const CONDITION_ROUTE_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Reply',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 130 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Messages', value: 'Draft a standard reply' }],
|
||||
},
|
||||
],
|
||||
@@ -320,7 +313,6 @@ export const CONDITION_MODERATE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Code', value: "throw 'blocked'" }],
|
||||
},
|
||||
{
|
||||
@@ -328,8 +320,7 @@ export const CONDITION_MODERATE_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Publish',
|
||||
type: 'api',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 700, y: 130 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Method', value: 'POST' }],
|
||||
},
|
||||
],
|
||||
@@ -377,7 +368,6 @@ export const CONDITION_ONBOARD_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Walk through SSO and SCIM' }],
|
||||
},
|
||||
{
|
||||
@@ -385,8 +375,7 @@ export const CONDITION_ONBOARD_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Quick start',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 130 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Messages', value: 'Send the 2-minute setup' }],
|
||||
},
|
||||
],
|
||||
@@ -433,7 +422,6 @@ export const FUNCTION_RESHAPE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Language', value: 'JavaScript' },
|
||||
{ title: 'Code', value: 'return <api.data>.profile' },
|
||||
@@ -477,7 +465,6 @@ export const FUNCTION_VALIDATE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'api',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Method', value: 'POST' },
|
||||
{ title: 'Body', value: '<clean.result>' },
|
||||
@@ -526,7 +513,6 @@ export const ROUTER_TRIAGE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Answer the pricing question' }],
|
||||
},
|
||||
{
|
||||
@@ -534,8 +520,7 @@ export const ROUTER_TRIAGE_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Support',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 95 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Messages', value: 'Help with the issue' }],
|
||||
},
|
||||
{
|
||||
@@ -543,8 +528,7 @@ export const ROUTER_TRIAGE_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Billing',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 190 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 280 },
|
||||
rows: [{ title: 'Messages', value: 'Resolve the billing question' }],
|
||||
},
|
||||
],
|
||||
@@ -584,7 +568,6 @@ export const RESPONSE_API_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Data', value: '{ "answer": <agent.content> }' },
|
||||
{ title: 'Status', value: '200' },
|
||||
@@ -629,7 +612,6 @@ export const ROUTER_CLASSIFY_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'workflow',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Workflow', value: 'product-intake' }],
|
||||
},
|
||||
{
|
||||
@@ -637,8 +619,7 @@ export const ROUTER_CLASSIFY_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Bug report',
|
||||
type: 'workflow',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 700, y: 110 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Workflow', value: 'bug-triage' }],
|
||||
},
|
||||
],
|
||||
@@ -681,7 +662,6 @@ export const ROUTER_LEAD_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Book a sales call' }],
|
||||
},
|
||||
{
|
||||
@@ -689,8 +669,7 @@ export const ROUTER_LEAD_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Self-serve',
|
||||
type: 'workflow',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 700, y: 110 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Workflow', value: 'onboarding' }],
|
||||
},
|
||||
],
|
||||
@@ -739,7 +718,6 @@ export const RESPONSE_WEBHOOK_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Data', value: '{ "received": true }' },
|
||||
{ title: 'Status', value: '200' },
|
||||
@@ -784,7 +762,6 @@ export const RESPONSE_ERROR_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Status', value: '200' }],
|
||||
},
|
||||
{
|
||||
@@ -792,8 +769,7 @@ export const RESPONSE_ERROR_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Response',
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 700, y: 130 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Status', value: '400' }],
|
||||
},
|
||||
],
|
||||
@@ -832,7 +808,6 @@ export const VARIABLES_RETRY_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'condition-if', label: 'If', value: '<variable.retryCount> < 3' },
|
||||
@@ -874,7 +849,6 @@ export const VARIABLES_CONFIG_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Personalize for <variable.userTier>' }],
|
||||
},
|
||||
],
|
||||
@@ -915,7 +889,6 @@ export const WAIT_RATELIMIT_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'api',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Method', value: 'GET' }],
|
||||
},
|
||||
],
|
||||
@@ -956,7 +929,6 @@ export const WAIT_FOLLOWUP_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Send a check-in' }],
|
||||
},
|
||||
],
|
||||
@@ -997,7 +969,6 @@ export const EVALUATOR_GATE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'condition-if', label: 'If', value: '<evaluator.accuracy> >= 4' },
|
||||
@@ -1034,7 +1005,6 @@ export const CREDENTIAL_SHARE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'gmail',
|
||||
bgColor: '#FFFFFF',
|
||||
position: { x: 380, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Account', value: '<credential.credentialId>' }],
|
||||
},
|
||||
{
|
||||
@@ -1042,8 +1012,7 @@ export const CREDENTIAL_SHARE_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Drive',
|
||||
type: 'google_drive',
|
||||
bgColor: '#FFFFFF',
|
||||
position: { x: 380, y: 100 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 380, y: 140 },
|
||||
rows: [{ title: 'Account', value: '<credential.credentialId>' }],
|
||||
},
|
||||
{
|
||||
@@ -1051,8 +1020,7 @@ export const CREDENTIAL_SHARE_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Calendar',
|
||||
type: 'google_calendar',
|
||||
bgColor: '#FFFFFF',
|
||||
position: { x: 380, y: 200 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 380, y: 280 },
|
||||
rows: [{ title: 'Account', value: '<credential.credentialId>' }],
|
||||
},
|
||||
],
|
||||
@@ -1095,7 +1063,6 @@ export const CREDENTIAL_ROUTE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'credential',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 700, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Account', value: 'Prod Slack' }],
|
||||
},
|
||||
{
|
||||
@@ -1103,8 +1070,7 @@ export const CREDENTIAL_ROUTE_WORKFLOW: PreviewWorkflow = {
|
||||
name: 'Credential',
|
||||
type: 'credential',
|
||||
bgColor: '#6366F1',
|
||||
position: { x: 700, y: 130 },
|
||||
hideSourceHandle: true,
|
||||
position: { x: 700, y: 140 },
|
||||
rows: [{ title: 'Account', value: 'Staging Slack' }],
|
||||
},
|
||||
],
|
||||
@@ -1135,7 +1101,6 @@ const GUARDRAILS_GATE = {
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
} as const
|
||||
|
||||
/** Guardrails example: validate JSON before parsing it. */
|
||||
@@ -1270,7 +1235,6 @@ export const HITL_APPROVAL_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'api',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Method', value: 'POST' }],
|
||||
},
|
||||
],
|
||||
@@ -1316,7 +1280,6 @@ export const HITL_MULTISTAGE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 880, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Code', value: 'apply()' }],
|
||||
},
|
||||
],
|
||||
@@ -1355,7 +1318,6 @@ export const HITL_VALIDATE_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Code', value: 'process()' }],
|
||||
},
|
||||
],
|
||||
@@ -1393,7 +1355,6 @@ export const WEBHOOK_NOTIFY_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'URL', value: 'hooks.slack.com/…' }],
|
||||
},
|
||||
],
|
||||
@@ -1435,7 +1396,6 @@ export const WEBHOOK_TRIGGER_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'webhook',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'URL', value: 'api.partner.com/hook' }],
|
||||
},
|
||||
],
|
||||
@@ -1476,7 +1436,6 @@ export const WORKFLOW_CALL_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 640, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Summarize <workflow.result>' }],
|
||||
},
|
||||
],
|
||||
@@ -1516,7 +1475,6 @@ export const LOOP_WORKFLOW: PreviewWorkflow = {
|
||||
bgColor: '#33C482',
|
||||
position: { x: 150, y: 62 },
|
||||
parentId: 'loop',
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Rate <loop.currentItem>' }],
|
||||
},
|
||||
{
|
||||
@@ -1525,7 +1483,6 @@ export const LOOP_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 860, y: 95 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Messages', value: 'Summarize <loop.results>' }],
|
||||
},
|
||||
],
|
||||
@@ -1566,7 +1523,6 @@ export const PARALLEL_WORKFLOW: PreviewWorkflow = {
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 150, y: 62 },
|
||||
parentId: 'parallel',
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'URL', value: '/tasks/<parallel.currentItem>' }],
|
||||
},
|
||||
{
|
||||
@@ -1575,13 +1531,17 @@ export const PARALLEL_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 860, y: 95 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Code', value: 'merge(<parallel.results>)' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-parallel', source: 'start', target: 'parallel' },
|
||||
{ id: 'parallel-call', source: 'parallel', target: 'call', sourceHandle: 'loop-start-source' },
|
||||
{
|
||||
id: 'parallel-call',
|
||||
source: 'parallel',
|
||||
target: 'call',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
{ id: 'parallel-aggregate', source: 'parallel', target: 'aggregate' },
|
||||
],
|
||||
}
|
||||
@@ -1621,7 +1581,6 @@ export const BUILD_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'response',
|
||||
bgColor: '#2F55FF',
|
||||
position: { x: 680, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Data', value: '{ "score": <agent.score> }' }],
|
||||
},
|
||||
],
|
||||
@@ -1665,7 +1624,6 @@ export const FILE_SUMMARY_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'file',
|
||||
bgColor: '#40916C',
|
||||
position: { x: 660, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Write' },
|
||||
{ title: 'File Name', value: 'summary.md' },
|
||||
@@ -1709,7 +1667,6 @@ export const TABLE_ROUNDTRIP_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'table',
|
||||
bgColor: '#10B981',
|
||||
position: { x: 680, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [
|
||||
{ title: 'Operation', value: 'Update Rows' },
|
||||
{ title: 'Set', value: "category, status = 'qualified'" },
|
||||
@@ -1779,7 +1736,6 @@ export const LEAD_SCORER_WORKFLOW: PreviewWorkflow = {
|
||||
type: 'google_sheets',
|
||||
bgColor: '#FFFFFF',
|
||||
position: { x: 1200, y: 0 },
|
||||
hideSourceHandle: true,
|
||||
rows: [{ title: 'Operation', value: 'Append' }],
|
||||
},
|
||||
],
|
||||
@@ -1790,3 +1746,223 @@ export const LEAD_SCORER_WORKFLOW: PreviewWorkflow = {
|
||||
{ id: 'score-log', source: 'score', target: 'log' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Blocks run as soon as they can" diagram on the how-it-runs page: two
|
||||
* agents that each depend only on Start, so they run concurrently.
|
||||
*/
|
||||
export const CONCURRENCY_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'concurrency',
|
||||
name: 'Run in parallel',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 80 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Ticket' }],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
name: 'Customer Support',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 360, y: 0 },
|
||||
rows: [{ title: 'Model', value: 'claude-sonnet-4-6' }],
|
||||
},
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Deep Researcher',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 360, y: 150 },
|
||||
rows: [{ title: 'Model', value: 'claude-sonnet-4-6' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-support', source: 'start', target: 'support' },
|
||||
{ id: 'start-research', source: 'start', target: 'research' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* The "A block waits for all its inputs" diagram: a Function that runs only
|
||||
* after both agents finish, reading both outputs.
|
||||
*/
|
||||
export const COMBINATION_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'combination',
|
||||
name: 'Wait for all inputs',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 80 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Ticket' }],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
name: 'Customer Support',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 360, y: 0 },
|
||||
rows: [{ title: 'Model', value: 'claude-sonnet-4-6' }],
|
||||
},
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Deep Researcher',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 360, y: 150 },
|
||||
rows: [{ title: 'Model', value: 'claude-sonnet-4-6' }],
|
||||
},
|
||||
{
|
||||
id: 'combine',
|
||||
name: 'Combine',
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 720, y: 75 },
|
||||
rows: [
|
||||
{ title: 'Language', value: 'JavaScript' },
|
||||
{ title: 'Code', value: 'merge(<support.content>, <research.content>)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-support', source: 'start', target: 'support' },
|
||||
{ id: 'start-research', source: 'start', target: 'research' },
|
||||
{ id: 'support-combine', source: 'support', target: 'combine' },
|
||||
{ id: 'research-combine', source: 'research', target: 'combine' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Branches follow one path" diagram: a Condition splits on an explicit
|
||||
* rule, and on one branch a Router lets a model choose among paths.
|
||||
*/
|
||||
export const ROUTING_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'routing',
|
||||
name: 'Branch by condition and router',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 140 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Message' }],
|
||||
},
|
||||
{
|
||||
id: 'condition',
|
||||
name: 'Condition',
|
||||
type: 'condition',
|
||||
bgColor: '#FF752F',
|
||||
position: { x: 340, y: 140 },
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'condition-if', label: 'If', value: "<start.type> === 'lead'" },
|
||||
{ id: 'condition-else', label: 'else' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'router',
|
||||
name: 'Router',
|
||||
type: 'router',
|
||||
bgColor: '#28C43F',
|
||||
position: { x: 740, y: 0 },
|
||||
rows: [],
|
||||
branches: [
|
||||
{ id: 'router-sales', label: 'Sales' },
|
||||
{ id: 'router-support', label: 'Support' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reply',
|
||||
name: 'Reply',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 740, y: 280 },
|
||||
rows: [{ title: 'Model', value: 'claude-sonnet-4-6' }],
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
name: 'Sales',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 1120, y: -60 },
|
||||
rows: [{ title: 'Model', value: 'claude-sonnet-4-6' }],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
name: 'Support',
|
||||
type: 'agent',
|
||||
bgColor: '#33C482',
|
||||
position: { x: 1120, y: 90 },
|
||||
rows: [{ title: 'Model', value: 'claude-sonnet-4-6' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-condition', source: 'start', target: 'condition' },
|
||||
{ id: 'condition-router', source: 'condition', target: 'router', sourceHandle: 'condition-if' },
|
||||
{ id: 'condition-reply', source: 'condition', target: 'reply', sourceHandle: 'condition-else' },
|
||||
{ id: 'router-sales', source: 'router', target: 'sales', sourceHandle: 'router-sales' },
|
||||
{ id: 'router-support', source: 'router', target: 'support', sourceHandle: 'router-support' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* The "When a block fails" diagram: a Function fails and the run leaves through
|
||||
* its red error port to a handler, while the normal-path block never runs.
|
||||
*/
|
||||
export const ERROR_PATH_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'error-path',
|
||||
name: 'Handle a failure',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
type: 'start_trigger',
|
||||
bgColor: '#2FB3FF',
|
||||
position: { x: 0, y: 80 },
|
||||
hideTargetHandle: true,
|
||||
rows: [{ title: 'Input', value: 'Order' }],
|
||||
},
|
||||
{
|
||||
id: 'throwError',
|
||||
name: 'throwError',
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 340, y: 80 },
|
||||
rows: [
|
||||
{ title: 'Language', value: 'JavaScript' },
|
||||
{ title: 'Code', value: 'throw new Error("failed")' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'handleSuccess',
|
||||
name: 'handleSuccess',
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 720, y: 0 },
|
||||
rows: [{ title: 'Code', value: 'return ok' }],
|
||||
},
|
||||
{
|
||||
id: 'handleError',
|
||||
name: 'handleError',
|
||||
type: 'function',
|
||||
bgColor: '#FF402F',
|
||||
position: { x: 720, y: 170 },
|
||||
rows: [{ title: 'Code', value: 'return recovered' }],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'start-throw', source: 'start', target: 'throwError' },
|
||||
{ id: 'throw-success', source: 'throwError', target: 'handleSuccess' },
|
||||
{ id: 'throw-error', source: 'throwError', target: 'handleError', sourceHandle: 'error' },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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 ? (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
|
||||
<span key={index} className='text-[var(--brand-secondary)]'>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,14 @@ export {
|
||||
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,
|
||||
@@ -28,6 +31,7 @@ export {
|
||||
ROUTER_CLASSIFY_WORKFLOW,
|
||||
ROUTER_LEAD_WORKFLOW,
|
||||
ROUTER_TRIAGE_WORKFLOW,
|
||||
ROUTING_WORKFLOW,
|
||||
SUPPORT_KB_WORKFLOW,
|
||||
TABLE_ENRICH_WORKFLOW,
|
||||
TABLE_ROUNDTRIP_WORKFLOW,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@sim/emcn'
|
||||
import { ChevronDown, Clipboard, Download, Search } from 'lucide-react'
|
||||
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
|
||||
import { BLOCK_ICONS } from '@/components/workflow-preview/block-icons'
|
||||
import { resolveIcon } from '@/components/workflow-preview/block-icons'
|
||||
|
||||
type ValueType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
|
||||
|
||||
/** Dark-theme equivalents of the app's type badges (string=green, number=blue, …). */
|
||||
const BADGE_COLORS: Record<ValueType, { bg: string; text: string }> = {
|
||||
string: { bg: 'var(--wp-badge-success-bg)', text: 'var(--wp-badge-success-text)' },
|
||||
number: { bg: 'var(--wp-badge-blue-bg)', text: 'var(--wp-badge-blue-text)' },
|
||||
boolean: { bg: 'var(--wp-badge-orange-bg)', text: 'var(--wp-badge-orange-text)' },
|
||||
array: { bg: 'var(--wp-badge-purple-bg)', text: 'var(--wp-badge-purple-text)' },
|
||||
object: { bg: 'var(--wp-badge-gray-bg)', text: 'var(--wp-badge-gray-text)' },
|
||||
null: { bg: 'var(--wp-badge-gray-bg)', text: 'var(--wp-badge-gray-text)' },
|
||||
}
|
||||
/** 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
|
||||
@@ -49,19 +49,11 @@ interface OutputBundleProps {
|
||||
values: OutputNode[]
|
||||
}
|
||||
|
||||
function resolveIcon(type: string) {
|
||||
return BLOCK_ICONS[type] ?? blockTypeToIconMap[type] ?? null
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: ValueType }) {
|
||||
const c = BADGE_COLORS[type]
|
||||
return (
|
||||
<span
|
||||
className='rounded-[4px] px-[5px] py-px text-[10px] leading-[14px]'
|
||||
style={{ background: c.bg, color: c.text }}
|
||||
>
|
||||
<Badge variant={TYPE_VARIANT[type]} size='sm'>
|
||||
{type}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,24 +65,24 @@ function TreeNode({ node, depth = 0 }: { node: OutputNode; depth?: number }) {
|
||||
<div className='flex min-h-[26px] items-center gap-2 rounded-[6px] px-1'>
|
||||
<span
|
||||
className='text-[13px]'
|
||||
style={{ color: node.highlight ? 'var(--wp-highlight)' : 'var(--wp-text)' }}
|
||||
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(--wp-text-muted)]'
|
||||
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(--wp-divider)] border-l pl-[10px]'>
|
||||
<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(--wp-text-2)]'>{node.value}</div>
|
||||
<div className='py-0.5 text-[13px] text-[var(--text-secondary)]'>{node.value}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -117,27 +109,27 @@ export function OutputBundle({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='wp-scope not-prose my-6 flex w-full max-w-[640px] flex-col gap-3'>
|
||||
<div className='flex overflow-hidden rounded-xl border border-[var(--wp-border)] bg-[var(--wp-panel)]'>
|
||||
<div className='flex w-[210px] flex-shrink-0 flex-col border-[var(--wp-border)] border-r px-2 py-2'>
|
||||
<div className='px-2 pb-2 text-[12px] text-[var(--wp-text-muted)]'>Logs</div>
|
||||
<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(--wp-active)' } : undefined}
|
||||
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(--wp-border-1)' }}
|
||||
style={{ background: row.color ?? 'var(--border-1)' }}
|
||||
>
|
||||
{Icon && <Icon className='size-[10px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate text-[13px] text-[var(--wp-text)]'>{row.name}</span>
|
||||
<span className='truncate text-[13px] text-[var(--text-primary)]'>{row.name}</span>
|
||||
{row.duration && (
|
||||
<span className='ml-auto text-[12px] text-[var(--wp-text-muted)]'>
|
||||
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>
|
||||
{row.duration}
|
||||
</span>
|
||||
)}
|
||||
@@ -148,9 +140,9 @@ export function OutputBundle({
|
||||
|
||||
<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(--wp-text)]'>Output</span>
|
||||
<span className='text-[13px] text-[var(--wp-text-muted)]'>Input</span>
|
||||
<span className='ml-auto flex items-center gap-2 text-[var(--wp-text-subtle)]'>
|
||||
<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]' />
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { blockTypeToIconMap } from '@/components/ui/icon-mapping'
|
||||
import { BLOCK_ICONS } from '@/components/workflow-preview/block-icons'
|
||||
import {
|
||||
BLOCK_STAGGER,
|
||||
EASE_OUT,
|
||||
type PreviewTool,
|
||||
} from '@/components/workflow-preview/workflow-data'
|
||||
|
||||
/** Core-block glyph first, then the integration icon map so diagrams can show tools too. */
|
||||
function resolveIcon(type: string) {
|
||||
return BLOCK_ICONS[type] ?? blockTypeToIconMap[type] ?? null
|
||||
}
|
||||
|
||||
interface PreviewBlockData {
|
||||
name: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
branches?: Array<{ id: string; label: string; value?: string }>
|
||||
showError?: boolean
|
||||
tools?: PreviewTool[]
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
index?: number
|
||||
animate?: boolean
|
||||
isHighlighted?: boolean
|
||||
isDimmed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle styling matching the real WorkflowBlock handles (--wp-edge mirrors
|
||||
* the app's --workflow-edge in both modes).
|
||||
*/
|
||||
const HANDLE_BASE = '!z-[10] !border-none !bg-[var(--wp-edge)]'
|
||||
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
|
||||
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
|
||||
const HANDLE_BRANCH =
|
||||
'!z-[10] !border-none !right-[-16px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px] !bg-[var(--wp-edge)]'
|
||||
const HANDLE_ERROR =
|
||||
'!z-[10] !border-none !right-[-16px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px] !bg-[var(--text-error)]'
|
||||
|
||||
/**
|
||||
* Static preview block node matching the real WorkflowBlock styling.
|
||||
* Renders a header (icon + name), sub-block rows, and tool chips.
|
||||
*
|
||||
* Colors come from preview-theme.module.css, mirroring the app's tokens in
|
||||
* both light and dark mode.
|
||||
*/
|
||||
export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
data,
|
||||
}: NodeProps<PreviewBlockData>) {
|
||||
const {
|
||||
name,
|
||||
blockType,
|
||||
bgColor,
|
||||
rows,
|
||||
branches,
|
||||
showError,
|
||||
tools,
|
||||
hideTargetHandle,
|
||||
hideSourceHandle,
|
||||
index = 0,
|
||||
animate = false,
|
||||
isHighlighted = false,
|
||||
isDimmed = false,
|
||||
} = data
|
||||
const Icon = resolveIcon(blockType)
|
||||
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||
const hasBranches = Boolean(branches && branches.length > 0)
|
||||
const hasContent =
|
||||
rows.length > 0 || hasBranches || Boolean(showError) || (tools && tools.length > 0)
|
||||
/* A block with branch handles emits from them, not from the header handle. */
|
||||
const showHeaderSource = !hideSourceHandle && !hasBranches
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<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 }}
|
||||
>
|
||||
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[var(--wp-border-1)] bg-[var(--wp-surface)]'>
|
||||
{isHighlighted && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-2 ring-[var(--wp-highlight)]' />
|
||||
)}
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={HANDLE_LEFT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex items-center justify-between p-2 ${hasContent ? 'border-[var(--wp-border-1)] border-b' : ''}`}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && <Icon className='size-[16px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[16px] text-[var(--wp-text)]'>{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasContent && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{rows.map((row) => (
|
||||
<div key={row.title} className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--wp-text-3)] capitalize'>
|
||||
{row.title}
|
||||
</span>
|
||||
{row.value && (
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[14px] text-[var(--wp-text)]'>
|
||||
<span className='truncate'>{row.value}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{branches?.map((branch) => (
|
||||
<div key={branch.id} className='relative flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--wp-text-3)] capitalize'>
|
||||
{branch.label}
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end font-normal text-[14px] text-[var(--wp-text)]'>
|
||||
<span className='truncate'>{branch.value ?? '-'}</span>
|
||||
</span>
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={branch.id}
|
||||
className={HANDLE_BRANCH}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showError && (
|
||||
<div className='relative flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--wp-text-3)] capitalize'>
|
||||
error
|
||||
</span>
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='error'
|
||||
className={HANDLE_ERROR}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tools && tools.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='flex-shrink-0 font-normal text-[14px] text-[var(--wp-text-3)]'>
|
||||
Tools
|
||||
</span>
|
||||
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon = resolveIcon(tool.type)
|
||||
return (
|
||||
<div
|
||||
key={tool.type}
|
||||
className='inline-flex h-5 items-center gap-1.5 rounded-md bg-[var(--wp-chip-bg)] px-1 shadow-[inset_0_0_0_1px_var(--wp-border-1)]'
|
||||
>
|
||||
<div
|
||||
className='flex size-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: tool.bgColor }}
|
||||
>
|
||||
{ToolIcon && <ToolIcon className='size-[9px] text-white' />}
|
||||
</div>
|
||||
<span className='font-normal text-[12px] text-[var(--wp-chip-text)] leading-5'>
|
||||
{tool.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHeaderSource && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={HANDLE_RIGHT}
|
||||
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
)
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { BLOCK_ICONS } from '@/components/workflow-preview/block-icons'
|
||||
|
||||
interface PreviewContainerData {
|
||||
name: string
|
||||
blockType: string
|
||||
bgColor: string
|
||||
rows: Array<{ title: string; value: string }>
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: boolean
|
||||
}
|
||||
|
||||
const HANDLE_BASE = '!z-[10] !border-none !bg-[var(--wp-edge)]'
|
||||
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
|
||||
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
|
||||
const HANDLE_START = `${HANDLE_BASE} !right-[-8px] !h-4 !w-[7px] !rounded-l-none !rounded-r-[2px]`
|
||||
|
||||
/** Legible icon class for the header swatch — dark glyph on a light bg, white on a dark one. */
|
||||
function iconClassFor(bg: string): string {
|
||||
const hex = bg.replace('#', '')
|
||||
if (hex.length !== 6) return 'text-white'
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16)
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16)
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance > 0.6 ? 'text-[#1c1c1c]' : 'text-white'
|
||||
}
|
||||
|
||||
/**
|
||||
* Container node for Loop / Parallel blocks, mirroring the app's subflow node:
|
||||
* a solid-bordered box with a header (icon + name), an internal "Start" pill
|
||||
* whose right handle feeds the first nested block, and target/source handles at
|
||||
* the vertical center. React Flow positions the child blocks inside via parentNode.
|
||||
*/
|
||||
export const PreviewContainerNode = memo(function PreviewContainerNode({
|
||||
data,
|
||||
}: NodeProps<PreviewContainerData>) {
|
||||
const { name, blockType, bgColor, hideTargetHandle, hideSourceHandle } = data
|
||||
const Icon = BLOCK_ICONS[blockType]
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full select-none rounded-[8px] border border-[var(--wp-border-1)] bg-[var(--wp-container-fill)]'>
|
||||
{!hideTargetHandle && (
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
id='target'
|
||||
className={HANDLE_LEFT}
|
||||
style={{ top: '50%', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='flex items-center gap-2.5 rounded-t-[8px] border-[var(--wp-border-1)] border-b bg-[var(--wp-header)] py-2 pr-3 pl-2'>
|
||||
<div
|
||||
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{Icon && <Icon className={`size-[16px] ${iconClassFor(bgColor)}`} />}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[16px] text-[var(--wp-text)]'>{name}</span>
|
||||
</div>
|
||||
|
||||
<div className='absolute top-[56px] left-4 flex items-center justify-center rounded-lg border border-[var(--wp-border-1)] bg-[var(--wp-header)] px-3 py-1.5'>
|
||||
<span className='font-medium text-[13px] text-[var(--wp-text)]'>Start</span>
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='loop-start-source'
|
||||
className={HANDLE_START}
|
||||
style={{ top: '50%', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hideSourceHandle && (
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='source'
|
||||
className={HANDLE_RIGHT}
|
||||
style={{ top: '50%', transform: 'translateY(-50%)' }}
|
||||
isConnectableStart={false}
|
||||
isConnectableEnd={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -26,12 +26,9 @@ export interface PreviewBlock {
|
||||
* blocks, `router-<routeId>` on Routers.
|
||||
*/
|
||||
branches?: Array<{ id: string; label: string; value?: string }>
|
||||
/** Render an error row with a red source handle (id `error`). */
|
||||
showError?: boolean
|
||||
tools?: PreviewTool[]
|
||||
position: { x: number; y: number }
|
||||
hideTargetHandle?: boolean
|
||||
hideSourceHandle?: 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. */
|
||||
@@ -51,8 +48,10 @@ export interface PreviewWorkflow {
|
||||
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(--wp-edge)', strokeWidth: 1.5 } as const
|
||||
const EDGE_STYLE_HIGHLIGHT = { stroke: 'var(--wp-highlight)', strokeWidth: 2.5 } as const
|
||||
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 {
|
||||
@@ -101,10 +100,9 @@ export function toReactFlowElements(
|
||||
bgColor: block.bgColor,
|
||||
rows: block.rows,
|
||||
branches: block.branches,
|
||||
showError: block.showError,
|
||||
tools: block.tools,
|
||||
hideTargetHandle: block.hideTargetHandle,
|
||||
hideSourceHandle: block.hideSourceHandle,
|
||||
size: block.size,
|
||||
index,
|
||||
animate,
|
||||
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
|
||||
@@ -122,6 +120,16 @@ export function toReactFlowElements(
|
||||
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,
|
||||
@@ -129,11 +137,11 @@ export function toReactFlowElements(
|
||||
type: 'previewEdge',
|
||||
animated: false,
|
||||
style: {
|
||||
...(isEdgeHighlight ? EDGE_STYLE_HIGHLIGHT : EDGE_STYLE),
|
||||
...(isEdgeHighlight ? EDGE_STYLE_HIGHLIGHT : isErrorEdge ? EDGE_STYLE_ERROR : EDGE_STYLE),
|
||||
opacity: dimmed ? 0.35 : 1,
|
||||
},
|
||||
sourceHandle: e.sourceHandle ?? 'source',
|
||||
targetHandle: 'target',
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
data: {
|
||||
animate,
|
||||
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||
|
||||
@@ -6,8 +6,6 @@ import { Maximize2, X } from 'lucide-react'
|
||||
import ReactFlow, {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
type EdgeTypes,
|
||||
@@ -19,9 +17,10 @@ import ReactFlow, {
|
||||
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 { PreviewBlockNode } from '@/components/workflow-preview/preview-block-node'
|
||||
import { PreviewContainerNode } from '@/components/workflow-preview/preview-container-node'
|
||||
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
|
||||
import { DocsContainerNode } from '@/components/workflow-preview/docs-container-node'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type PreviewBlock,
|
||||
@@ -59,6 +58,8 @@ function PreviewEdge({
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: 30,
|
||||
})
|
||||
|
||||
if (data?.animate) {
|
||||
@@ -89,8 +90,8 @@ function PreviewEdge({
|
||||
}
|
||||
|
||||
const NODE_TYPES: NodeTypes = {
|
||||
previewBlock: PreviewBlockNode,
|
||||
previewContainer: PreviewContainerNode,
|
||||
previewBlock: DocsBlockNode,
|
||||
previewContainer: DocsContainerNode,
|
||||
}
|
||||
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
|
||||
const PRO_OPTIONS = { hideAttribution: true }
|
||||
@@ -116,21 +117,41 @@ const SELECT_TITLES = new Set([
|
||||
])
|
||||
|
||||
function inspectorFieldsFor(block: PreviewBlock) {
|
||||
const rowFields = block.rows.map((row) => ({
|
||||
label: row.title,
|
||||
kind:
|
||||
TEXTAREA_TITLES.has(row.title) || row.value.length > 40
|
||||
? ('textarea' as const)
|
||||
: SELECT_TITLES.has(row.title)
|
||||
? ('select' as const)
|
||||
: ('input' as const),
|
||||
value: row.value,
|
||||
}))
|
||||
// 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,
|
||||
value: branch.value,
|
||||
placeholder: '—',
|
||||
// Match the canvas + the editor's getDisplayValue: a blank value reads as '-'.
|
||||
value: branch.value || '-',
|
||||
}))
|
||||
return [...rowFields, ...branchFields]
|
||||
}
|
||||
@@ -143,10 +164,12 @@ function PreviewFlow({
|
||||
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 }),
|
||||
@@ -156,8 +179,18 @@ function PreviewFlow({
|
||||
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(initialNodes)
|
||||
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])
|
||||
|
||||
@@ -177,6 +210,7 @@ function PreviewFlow({
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick ? (_, node) => onNodeClick(node.id) : undefined}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||
@@ -186,18 +220,16 @@ function PreviewFlow({
|
||||
zoomOnScroll={interactive}
|
||||
zoomOnDoubleClick={interactive}
|
||||
panOnScroll={false}
|
||||
zoomOnPinch={interactive}
|
||||
zoomOnPinch
|
||||
panOnDrag
|
||||
preventScrolling={interactive}
|
||||
autoPanOnNodeDrag={false}
|
||||
proOptions={PRO_OPTIONS}
|
||||
minZoom={0.2}
|
||||
minZoom={0.1}
|
||||
fitView
|
||||
fitViewOptions={interactive ? LIGHTBOX_FIT_VIEW_OPTIONS : FIT_VIEW_OPTIONS}
|
||||
className='h-full w-full'
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color='var(--wp-border)' />
|
||||
</ReactFlow>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,7 +246,7 @@ function PreviewFlow({
|
||||
*/
|
||||
export function WorkflowPreview({
|
||||
workflow,
|
||||
height = 260,
|
||||
height = 340,
|
||||
animate = false,
|
||||
highlightBlock,
|
||||
highlightEdge,
|
||||
@@ -230,21 +262,17 @@ export function WorkflowPreview({
|
||||
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 incoming = selectedBlock
|
||||
? workflow.edges
|
||||
.filter((e) => e.target === selectedBlock.id)
|
||||
.map((e) => workflow.blocks.find((b) => b.id === e.source))
|
||||
.filter((b): b is PreviewBlock => Boolean(b))
|
||||
: []
|
||||
|
||||
const openWith = (blockId: string | null) => {
|
||||
setSelectedId(blockId)
|
||||
@@ -254,7 +282,7 @@ export function WorkflowPreview({
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div
|
||||
className='wp-scope not-prose group relative my-6 overflow-hidden rounded-xl border border-[var(--wp-border)] bg-[var(--wp-canvas)]'
|
||||
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 ?? ''}`}>
|
||||
@@ -264,13 +292,14 @@ export function WorkflowPreview({
|
||||
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(--wp-border-1)] bg-[var(--wp-btn)] text-[var(--wp-text-muted)] opacity-0 transition-opacity duration-150 hover:text-[var(--wp-text)] group-hover:opacity-100'
|
||||
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>
|
||||
@@ -284,19 +313,19 @@ export function WorkflowPreview({
|
||||
role='presentation'
|
||||
>
|
||||
<div
|
||||
className='wp-scope relative flex h-[86vh] w-[92vw] overflow-hidden rounded-xl border border-[var(--wp-border)] bg-[var(--wp-canvas)]'
|
||||
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(--wp-text-muted)]'>{workflow.name}</span>
|
||||
<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(--wp-border-1)] bg-[var(--wp-btn)] text-[var(--wp-text-muted)] transition-colors hover:text-[var(--wp-text)]'
|
||||
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>
|
||||
@@ -309,11 +338,12 @@ export function WorkflowPreview({
|
||||
selectedBlock={selectedId ?? undefined}
|
||||
interactive
|
||||
onNodeClick={(id) => setSelectedId(id)}
|
||||
onPaneClick={() => setSelectedId(null)}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
<div className='w-[340px] flex-shrink-0 border-[var(--wp-border)] border-l'>
|
||||
<div className='w-[340px] flex-shrink-0 border-[var(--border)] border-l'>
|
||||
{selectedBlock ? (
|
||||
<BlockInspector
|
||||
embedded
|
||||
@@ -322,14 +352,9 @@ export function WorkflowPreview({
|
||||
color={selectedBlock.bgColor}
|
||||
fields={inspectorFieldsFor(selectedBlock)}
|
||||
tools={selectedBlock.tools}
|
||||
connections={incoming.map((b) => ({
|
||||
name: b.name,
|
||||
type: b.type,
|
||||
color: b.bgColor,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center px-6 text-center text-[13px] text-[var(--wp-text-muted)]'>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,13 @@ pageType: concept
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import {
|
||||
COMBINATION_WORKFLOW,
|
||||
CONCURRENCY_WORKFLOW,
|
||||
ERROR_PATH_WORKFLOW,
|
||||
ROUTING_WORKFLOW,
|
||||
WorkflowPreview,
|
||||
} from '@/components/workflow-preview'
|
||||
|
||||
When you run a workflow, Sim works out the order from the [connections](/workflows/connections): a block runs as soon as the blocks it depends on have finished.
|
||||
|
||||
@@ -14,7 +20,7 @@ When you run a workflow, Sim works out the order from the [connections](/workflo
|
||||
|
||||
Multiple blocks in a workflow can be executing at the same time. A block starts the moment its dependencies finish, and it waits on nothing else.
|
||||
|
||||
<Image src="/static/execution/concurrency.png" alt="Two agent blocks running after the Start trigger" width={800} height={500} />
|
||||
<WorkflowPreview workflow={CONCURRENCY_WORKFLOW} height={220} />
|
||||
|
||||
Here the Customer Support and Deep Researcher agents each depend only on Start, so neither waits for the other.
|
||||
|
||||
@@ -22,7 +28,7 @@ Here the Customer Support and Deep Researcher agents each depend only on Start,
|
||||
|
||||
When several blocks feed into one, that block waits for every feeder that is going to run, then runs once with each of their outputs available to read. A feeder on a branch that wasn't taken doesn't hold it up. You don't merge the outputs yourself.
|
||||
|
||||
<Image src="/static/execution/combination.png" alt="A Function block running after two agent blocks complete" width={800} height={500} />
|
||||
<WorkflowPreview workflow={COMBINATION_WORKFLOW} height={220} />
|
||||
|
||||
The Function block here runs after both agents complete, with both of their outputs ready.
|
||||
|
||||
@@ -30,7 +36,7 @@ The Function block here runs after both agents complete, with both of their outp
|
||||
|
||||
A workflow can split. A [Condition](/workflows/blocks/condition) block branches on an explicit rule; a [Router](/workflows/blocks/router) block lets a model choose the path. Only the branch that is taken runs. A block on a branch that didn't run produces no output, which is why a [connection tag](/workflows/connections) pointing at it comes back empty.
|
||||
|
||||
<Image src="/static/execution/routing.png" alt="A workflow branching by condition and by router" width={800} height={500} />
|
||||
<WorkflowPreview workflow={ROUTING_WORKFLOW} height={400} />
|
||||
|
||||
To repeat work, a [Loop](/workflows/blocks/loop) block runs its inner blocks over a list, a count, or while a condition holds, and a [Parallel](/workflows/blocks/parallel) block runs them for several items at once.
|
||||
|
||||
@@ -42,7 +48,7 @@ A workflow can call other workflows, through a Workflow block, an MCP tool, or a
|
||||
|
||||
A block that errors fails the run: blocks already running finish, and nothing new starts. To handle the failure instead, connect the block's **error port** — the run follows the [error path](/workflows/connections) and continues.
|
||||
|
||||
<Image src="/static/execution/error-path.png" alt="A Function block whose error port routes to a handler block, with the normal path continuing to a success block" width={800} height={319} />
|
||||
<WorkflowPreview workflow={ERROR_PATH_WORKFLOW} height={260} />
|
||||
|
||||
Here `throwError` fails, so the run leaves through its red error port to `handleError`; `handleSuccess` on the normal path never runs.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const withMDX = createMDX()
|
||||
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@sim/emcn'],
|
||||
transpilePackages: ['@sim/emcn', '@sim/workflow-renderer'],
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@ai-sdk/react": "2.0.205",
|
||||
"@sim/db": "workspace:*",
|
||||
"@sim/emcn": "workspace:*",
|
||||
"@sim/workflow-renderer": "workspace:*",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"ai": "5.0.203",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -34,6 +35,7 @@
|
||||
"postgres": "^3.4.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"shiki": "4.0.0",
|
||||
"streamdown": "2.5.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
|
||||
+16
-30
@@ -617,7 +617,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
*/
|
||||
const conditionRows = useMemo(() => {
|
||||
if (type !== 'condition') return [] as { id: string; title: string; value: string }[]
|
||||
return getConditionRows(id, topologySubBlocks.conditions?.value)
|
||||
return getConditionRows(id, topologySubBlocks.conditions?.value).map((cond) => ({
|
||||
...cond,
|
||||
value: getDisplayValue(cond.value),
|
||||
}))
|
||||
}, [type, topologySubBlocks, id])
|
||||
|
||||
/**
|
||||
@@ -627,7 +630,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
*/
|
||||
const routerRows = useMemo(() => {
|
||||
if (type !== 'router_v2') return [] as { id: string; value: string }[]
|
||||
return getRouterRows(id, topologySubBlocks.routes?.value)
|
||||
return getRouterRows(id, topologySubBlocks.routes?.value).map((route) => ({
|
||||
...route,
|
||||
value: getDisplayValue(route.value),
|
||||
}))
|
||||
}, [type, topologySubBlocks, id])
|
||||
|
||||
/**
|
||||
@@ -699,29 +705,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined
|
||||
|
||||
const rows = (
|
||||
<>
|
||||
{type === 'condition' ? (
|
||||
conditionRows.map((cond) => (
|
||||
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
|
||||
))
|
||||
) : type === 'router_v2' ? (
|
||||
<>
|
||||
<SubBlockRow
|
||||
key='context'
|
||||
title='Context'
|
||||
value={getDisplayValue(subBlockState.context?.value)}
|
||||
/>
|
||||
{routerRows.map((route, index) => (
|
||||
<SubBlockRow
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
value={getDisplayValue(route.value)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
subBlockRows.map((row, rowIndex) =>
|
||||
const rows =
|
||||
type === 'condition' || type === 'router_v2' ? null : (
|
||||
<>
|
||||
{subBlockRows.map((row, rowIndex) =>
|
||||
row.flatMap((subBlock) => {
|
||||
const rawValue = subBlockState[subBlock.id]?.value
|
||||
if (subBlock.type === 'mcp-dynamic-args') {
|
||||
@@ -761,11 +748,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
/>,
|
||||
]
|
||||
})
|
||||
)
|
||||
)}
|
||||
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkflowBlockView
|
||||
@@ -785,6 +770,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
hasContentBelowHeader={hasContentBelowHeader}
|
||||
conditionRows={conditionRows}
|
||||
routerRows={routerRows}
|
||||
routerContextValue={getDisplayValue(subBlockState.context?.value)}
|
||||
wouldCreateConnectionCycle={wouldCreateConnectionCycle}
|
||||
isWorkflowSelector={isWorkflowSelector}
|
||||
childWorkflowId={childWorkflowId}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@ai-sdk/react": "2.0.205",
|
||||
"@sim/db": "workspace:*",
|
||||
"@sim/emcn": "workspace:*",
|
||||
"@sim/workflow-renderer": "workspace:*",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"ai": "5.0.203",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -43,6 +44,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"reactflow": "^11.11.4",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"shiki": "4.0.0",
|
||||
"streamdown": "2.5.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
@@ -532,6 +534,7 @@
|
||||
"name": "@sim/workflow-renderer",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@sim/emcn": "workspace:*",
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/react": "^19",
|
||||
"lucide-react": "^0.479.0",
|
||||
@@ -542,6 +545,7 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sim/emcn": "workspace:*",
|
||||
"lucide-react": ">=0.479.0",
|
||||
"react": "^19",
|
||||
"reactflow": "^11.11.4",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"format:check": "biome format ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sim/emcn": "workspace:*",
|
||||
"lucide-react": ">=0.479.0",
|
||||
"react": "^19",
|
||||
"reactflow": "^11.11.4",
|
||||
@@ -33,6 +34,7 @@
|
||||
"streamdown": ">=2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/emcn": "workspace:*",
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/react": "^19",
|
||||
"lucide-react": "^0.479.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '../dimensions'
|
||||
import type { BlockRunStatus } from '../types'
|
||||
import { SubBlockRowView } from './sub-block-row-view'
|
||||
|
||||
/**
|
||||
* Reusable styles and positioning for Handle components.
|
||||
@@ -62,32 +63,34 @@ export interface WorkflowBlockViewProps {
|
||||
hasContentBelowHeader: boolean
|
||||
conditionRows: { id: string; title: string; value: string }[]
|
||||
routerRows: { id: string; value: string }[]
|
||||
/** Router 'Context' summary-row value (router_v2 only). */
|
||||
routerContextValue?: string
|
||||
/** Connection-cycle guard; reads fresh edge state on every call. */
|
||||
wouldCreateConnectionCycle: (source: string, target: string) => boolean
|
||||
|
||||
/** Child-workflow deploy badge state. */
|
||||
isWorkflowSelector: boolean
|
||||
/** Child-workflow deploy badge state — editor-only; omit in read-only contexts. */
|
||||
isWorkflowSelector?: boolean
|
||||
childWorkflowId?: string
|
||||
childIsDeployed: boolean | null
|
||||
childNeedsRedeploy: boolean
|
||||
isDeploying: boolean
|
||||
canAdmin: boolean
|
||||
onDeployChild: () => void
|
||||
childIsDeployed?: boolean | null
|
||||
childNeedsRedeploy?: boolean
|
||||
isDeploying?: boolean
|
||||
canAdmin?: boolean
|
||||
onDeployChild?: () => void
|
||||
|
||||
/** Schedule badge state. */
|
||||
shouldShowScheduleBadge: boolean
|
||||
scheduleIsDisabled: boolean
|
||||
onReactivateSchedule: () => void
|
||||
/** Schedule badge state — editor-only; omit in read-only contexts. */
|
||||
shouldShowScheduleBadge?: boolean
|
||||
scheduleIsDisabled?: boolean
|
||||
onReactivateSchedule?: () => void
|
||||
|
||||
/** Webhook badge state. */
|
||||
showWebhookIndicator: boolean
|
||||
/** Webhook badge state — editor-only; omit in read-only contexts. */
|
||||
showWebhookIndicator?: boolean
|
||||
webhookProvider?: string
|
||||
webhookPath?: string
|
||||
webhookProviderName?: string
|
||||
isWebhookConfigured: boolean
|
||||
isWebhookDisabled: boolean
|
||||
isWebhookConfigured?: boolean
|
||||
isWebhookDisabled?: boolean
|
||||
webhookId?: string
|
||||
onReactivateWebhook: () => void
|
||||
onReactivateWebhook?: () => void
|
||||
|
||||
/** Selects this block in the editor panel. */
|
||||
onSelect: () => void
|
||||
@@ -95,7 +98,11 @@ export interface WorkflowBlockViewProps {
|
||||
contentRef?: Ref<HTMLDivElement>
|
||||
/** Editor-only action bar; omit in read-only / preview contexts. */
|
||||
actionBar?: ReactNode
|
||||
/** Collapsed subblock summary rows, built by the container. */
|
||||
/**
|
||||
* Non-branch collapsed subblock summary rows, built by the container.
|
||||
* Condition/router/error rows are rendered by the view itself from
|
||||
* conditionRows/routerRows.
|
||||
*/
|
||||
rows: ReactNode
|
||||
}
|
||||
|
||||
@@ -121,6 +128,7 @@ export function WorkflowBlockView({
|
||||
hasContentBelowHeader,
|
||||
conditionRows,
|
||||
routerRows,
|
||||
routerContextValue,
|
||||
wouldCreateConnectionCycle,
|
||||
isWorkflowSelector,
|
||||
childWorkflowId,
|
||||
@@ -221,7 +229,7 @@ export function WorkflowBlockView({
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeployChild()
|
||||
onDeployChild?.()
|
||||
}}
|
||||
>
|
||||
{isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'}
|
||||
@@ -250,7 +258,7 @@ export function WorkflowBlockView({
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReactivateSchedule()
|
||||
onReactivateSchedule?.()
|
||||
}}
|
||||
>
|
||||
disabled
|
||||
@@ -293,7 +301,7 @@ export function WorkflowBlockView({
|
||||
dot
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReactivateWebhook()
|
||||
onReactivateWebhook?.()
|
||||
}}
|
||||
>
|
||||
disabled
|
||||
@@ -315,7 +323,29 @@ export function WorkflowBlockView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasContentBelowHeader && <div className='flex flex-col gap-2 p-2'>{rows}</div>}
|
||||
{hasContentBelowHeader && (
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
{type === 'condition' ? (
|
||||
conditionRows.map((cond) => (
|
||||
<SubBlockRowView key={cond.id} title={cond.title} displayValue={cond.value} />
|
||||
))
|
||||
) : type === 'router_v2' ? (
|
||||
<>
|
||||
<SubBlockRowView key='context' title='Context' displayValue={routerContextValue} />
|
||||
{routerRows.map((route, index) => (
|
||||
<SubBlockRowView
|
||||
key={route.id}
|
||||
title={`Route ${index + 1}`}
|
||||
displayValue={route.value}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
rows
|
||||
)}
|
||||
{shouldShowDefaultHandles && <SubBlockRowView title='error' />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'condition' && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user