Compare commits

..

12 Commits

Author SHA1 Message Date
Emir Karabeg
bed37ab6fc feat(terminal): added collapse JSON 2026-01-26 22:50:59 -08:00
Waleed
d8df08d3d3 improvement(mcp): remove mcp-remote for cursor config (#3020) 2026-01-26 19:54:27 -08:00
Vikhyath Mondreti
51891daf9a feat(code): undo-redo state (#3018)
* feat(code): undo-redo state

* address greptile

* address bugbot comments

* fix debounce flush

* inc debounce time

* fix wand case

* address comments
2026-01-26 19:40:40 -08:00
Vikhyath Mondreti
9ee5dfe185 improvement(workflow): hide raw json childworkflow span (#3019) 2026-01-26 18:47:35 -08:00
Emir Karabeg
9cba8eee48 improvement(preview): error paths, loops, workflow (#3010)
* improvement(switch): dark styling

* improvement(settings): change deployed MCPs to MCPs servers

* improvement(preview): added error paths, loop logic

* improvement(preview): nested workflows preview

* feat(preview): lightweight param

* improvement(preview): staging changes integrated
2026-01-26 17:57:48 -08:00
Siddharth Ganesan
cb650132c7 fix(executor): fix. convergent error edges (#3015) 2026-01-26 17:25:09 -08:00
Waleed
9dbf56f9cd feat(note-block): expand media embed support with tuned aspect ratios (#3016)
* feat(note-block): expand media embed support with tuned aspect ratios

* fix(note-block): add artist parameter to Bandcamp embed URLs

Include the artist subdomain in Bandcamp track and album embed URLs
to ensure proper embed resolution.

* fix(note-block): add required src attribute to track elements

HTML spec requires track elements to have a src attribute.

* fix(note-block): address embed URL matching issues

- Fix YouTube regex to handle v= anywhere in query params
- Fix Twitch channel match to exclude /clip/ URLs
- Remove Mux support (HLS not supported in most browsers)
- Remove Bandcamp support (requires numeric IDs, not slugs)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 17:22:44 -08:00
Vikhyath Mondreti
5189473e06 fix(max-tokens): anthropic models streaming vs non-streaming (#2999)
* fix(max-tokens): anthropic models streaming vs non-streaming

* default max inc

* update the docs
2026-01-26 17:05:33 -08:00
Vikhyath Mondreti
37900988eb fix(kb): workspace id required for creation (#3001)
* fix(kb): workspace id required for creation

* fix tests
2026-01-26 16:27:07 -08:00
Vikhyath Mondreti
3cc9b1ae56 fix(input-format): resolution for blocks with input format fields (#3012)
* fix input format

* fix tests

* address bugbot comment
2026-01-26 16:04:19 -08:00
Waleed
3ccbee187d improvement(docs): updated logo, added lightbox to action media, fixed minor styling inconsistencies between themes (#3014)
* improvement(docs): updated logo, added lightbox to action media, fixed minor styling inconsistencies between themes

* updated og image

* ack comments
2026-01-26 15:49:23 -08:00
Waleed
36945deaa5 improvement(preview): consolidate block rendering and fix handle configurations (#3013)
* improvement(preview): consolidate block rendering and fix handle configurations

* refactor(preview): extract SubflowContainerProps interface
2026-01-26 14:56:06 -08:00
94 changed files with 3780 additions and 2817 deletions

View File

@@ -185,11 +185,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
tableOfContent={{
style: 'clerk',
enabled: true,
header: (
<div key='toc-header' className='mb-2 font-medium text-sm'>
On this page
</div>
),
footer: <TOCFooter />,
single: false,
}}

View File

@@ -3,13 +3,13 @@ import { defineI18nUI } from 'fumadocs-ui/i18n'
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
import { RootProvider } from 'fumadocs-ui/provider/next'
import { Geist_Mono, Inter } from 'next/font/google'
import Image from 'next/image'
import {
SidebarFolder,
SidebarItem,
SidebarSeparator,
} from '@/components/docs-layout/sidebar-components'
import { Navbar } from '@/components/navbar/navbar'
import { SimLogoFull } from '@/components/ui/sim-logo'
import { i18n } from '@/lib/i18n'
import { source } from '@/lib/source'
import '../global.css'
@@ -102,16 +102,7 @@ export default async function Layout({ children, params }: LayoutProps) {
<DocsLayout
tree={source.pageTree[lang]}
nav={{
title: (
<Image
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
priority
/>
),
title: <SimLogoFull className='h-7 w-auto' />,
}}
sidebar={{
defaultOpenLevel: 0,

View File

@@ -33,15 +33,41 @@ async function loadGoogleFont(font: string, weights: string, text: string): Prom
throw new Error('Failed to load font data')
}
/**
* Sim logo with icon and "Sim" text for OG image.
*/
function SimLogoFull() {
return (
<svg height='28' viewBox='720 440 1020 320' fill='none'>
{/* Green icon - top left shape with cutout */}
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
{/* Green icon - bottom right square */}
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
{/* "Sim" text - white for dark background */}
<path
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
fill='#fafafa'
/>
</svg>
)
}
/**
* Generates dynamic Open Graph images for documentation pages.
* Style matches Cursor docs: dark background, title at top, logo bottom-left, domain bottom-right.
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') || 'Documentation'
const baseUrl = new URL(request.url).origin
const allText = `${title}docs.sim.ai`
const fontData = await loadGoogleFont('Geist', '400;500;600', allText)
@@ -52,84 +78,39 @@ export async function GET(request: NextRequest) {
width: '100%',
display: 'flex',
flexDirection: 'column',
background: '#0c0c0c',
position: 'relative',
justifyContent: 'space-between',
padding: '56px 64px',
background: '#121212', // Dark mode background matching docs (hsla 0, 0%, 7%)
fontFamily: 'Geist',
}}
>
{/* Base gradient layer - subtle purple tint across the entire image */}
<div
{/* Title at top */}
<span
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'radial-gradient(ellipse 150% 100% at 50% 100%, rgba(88, 28, 135, 0.15) 0%, rgba(88, 28, 135, 0.08) 25%, rgba(88, 28, 135, 0.03) 50%, transparent 80%)',
display: 'flex',
}}
/>
{/* Secondary glow - adds depth without harsh edges */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'radial-gradient(ellipse 100% 80% at 80% 90%, rgba(112, 31, 252, 0.12) 0%, rgba(112, 31, 252, 0.04) 40%, transparent 70%)',
display: 'flex',
}}
/>
{/* Top darkening - creates natural vignette */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, transparent 40%, transparent 100%)',
display: 'flex',
}}
/>
{/* Content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '56px 72px',
height: '100%',
justifyContent: 'space-between',
fontSize: getTitleFontSize(title),
fontWeight: 500,
color: '#fafafa', // Light text matching docs
lineHeight: 1.2,
letterSpacing: '-0.02em',
}}
>
{/* Logo */}
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
{title}
</span>
{/* Title */}
<span
style={{
fontSize: getTitleFontSize(title),
fontWeight: 600,
color: '#ffffff',
lineHeight: 1.1,
letterSpacing: '-0.02em',
}}
>
{title}
</span>
{/* Footer */}
{/* Footer: icon left, domain right */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<SimLogoFull />
<span
style={{
fontSize: 20,
fontWeight: 500,
fontWeight: 400,
color: '#71717a',
}}
>

View File

@@ -9,11 +9,20 @@ body {
}
@theme {
--color-fd-primary: #802fff; /* Purple from control-bar component */
--color-fd-primary: #33c482; /* Green from Sim logo */
--font-geist-sans: var(--font-geist-sans);
--font-geist-mono: var(--font-geist-mono);
}
/* Ensure primary color is set in both light and dark modes */
:root {
--color-fd-primary: #33c482;
}
.dark {
--color-fd-primary: #33c482;
}
/* Font family utilities */
.font-sans {
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
@@ -34,7 +43,7 @@ body {
:root {
--fd-border: transparent !important;
--fd-border-sidebar: transparent !important;
--fd-nav-height: 64px; /* Custom navbar height (h-16 = 4rem = 64px) */
--fd-nav-height: 65px; /* Custom navbar height (h-16 = 64px + 1px border) */
/* Content container width used to center main content */
--spacing-fd-container: 1400px;
/* Edge gutter = leftover space on each side of centered container */
@@ -136,11 +145,11 @@ aside#nd-sidebar {
/* On mobile, let fumadocs handle the layout natively */
@media (min-width: 1024px) {
:root {
--fd-banner-height: 64px !important;
--fd-banner-height: 65px !important; /* 64px navbar + 1px border */
}
#nd-docs-layout {
--fd-docs-height: calc(100dvh - 64px) !important;
--fd-docs-height: calc(100dvh - 65px) !important; /* 64px navbar + 1px border */
--fd-sidebar-width: 300px !important;
margin-left: var(--sidebar-offset) !important;
margin-right: var(--toc-offset) !important;
@@ -227,19 +236,19 @@ html:not(.dark) #nd-sidebar button:not([aria-label*="ollapse"]):not([aria-label*
letter-spacing: 0.05em !important;
}
/* Override active state (NO PURPLE) */
/* Override active state */
#nd-sidebar a[data-active="true"],
#nd-sidebar button[data-active="true"],
#nd-sidebar a.bg-fd-primary\/10,
#nd-sidebar a.text-fd-primary,
#nd-sidebar a[class*="bg-fd-primary"],
#nd-sidebar a[class*="text-fd-primary"],
/* Override custom sidebar purple classes */
/* Override custom sidebar green classes */
#nd-sidebar
a.bg-purple-50\/80,
#nd-sidebar a.text-purple-600,
#nd-sidebar a[class*="bg-purple"],
#nd-sidebar a[class*="text-purple"] {
a.bg-emerald-50\/80,
#nd-sidebar a.text-emerald-600,
#nd-sidebar a[class*="bg-emerald"],
#nd-sidebar a[class*="text-emerald"] {
background-image: none !important;
}
@@ -250,10 +259,10 @@ html.dark #nd-sidebar a.bg-fd-primary\/10,
html.dark #nd-sidebar a.text-fd-primary,
html.dark #nd-sidebar a[class*="bg-fd-primary"],
html.dark #nd-sidebar a[class*="text-fd-primary"],
html.dark #nd-sidebar a.bg-purple-50\/80,
html.dark #nd-sidebar a.text-purple-600,
html.dark #nd-sidebar a[class*="bg-purple"],
html.dark #nd-sidebar a[class*="text-purple"] {
html.dark #nd-sidebar a.bg-emerald-50\/80,
html.dark #nd-sidebar a.text-emerald-600,
html.dark #nd-sidebar a[class*="bg-emerald"],
html.dark #nd-sidebar a[class*="text-emerald"] {
background-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 1) !important;
}
@@ -265,10 +274,10 @@ html:not(.dark) #nd-sidebar a.bg-fd-primary\/10,
html:not(.dark) #nd-sidebar a.text-fd-primary,
html:not(.dark) #nd-sidebar a[class*="bg-fd-primary"],
html:not(.dark) #nd-sidebar a[class*="text-fd-primary"],
html:not(.dark) #nd-sidebar a.bg-purple-50\/80,
html:not(.dark) #nd-sidebar a.text-purple-600,
html:not(.dark) #nd-sidebar a[class*="bg-purple"],
html:not(.dark) #nd-sidebar a[class*="text-purple"] {
html:not(.dark) #nd-sidebar a.bg-emerald-50\/80,
html:not(.dark) #nd-sidebar a.text-emerald-600,
html:not(.dark) #nd-sidebar a[class*="bg-emerald"],
html:not(.dark) #nd-sidebar a[class*="text-emerald"] {
background-color: rgba(0, 0, 0, 0.07) !important;
color: rgba(0, 0, 0, 0.9) !important;
}
@@ -286,8 +295,8 @@ html:not(.dark) #nd-sidebar button:hover:not([data-active="true"]) {
}
/* Dark mode - ensure active/selected items don't change on hover */
html.dark #nd-sidebar a.bg-purple-50\/80:hover,
html.dark #nd-sidebar a[class*="bg-purple"]:hover,
html.dark #nd-sidebar a.bg-emerald-50\/80:hover,
html.dark #nd-sidebar a[class*="bg-emerald"]:hover,
html.dark #nd-sidebar a[data-active="true"]:hover,
html.dark #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(255, 255, 255, 0.15) !important;
@@ -295,8 +304,8 @@ html.dark #nd-sidebar button[data-active="true"]:hover {
}
/* Light mode - ensure active/selected items don't change on hover */
html:not(.dark) #nd-sidebar a.bg-purple-50\/80:hover,
html:not(.dark) #nd-sidebar a[class*="bg-purple"]:hover,
html:not(.dark) #nd-sidebar a.bg-emerald-50\/80:hover,
html:not(.dark) #nd-sidebar a[class*="bg-emerald"]:hover,
html:not(.dark) #nd-sidebar a[data-active="true"]:hover,
html:not(.dark) #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(0, 0, 0, 0.07) !important;
@@ -368,16 +377,24 @@ aside[data-sidebar] > *:not([data-sidebar-viewport]) {
button[aria-label="Toggle Sidebar"],
button[aria-label="Collapse Sidebar"],
/* Hide nav title/logo in sidebar on desktop - target all possible locations */
/* Lower specificity selectors first (attribute selectors) */
[data-sidebar-header],
[data-sidebar] [data-title],
aside[data-sidebar] a[href="/"],
aside[data-sidebar] a[href="/"] img,
aside[data-sidebar] > a:first-child,
aside[data-sidebar] > div > a:first-child,
aside[data-sidebar] img[alt="Sim"],
[data-sidebar-header],
[data-sidebar] [data-title],
aside[data-sidebar] svg[aria-label="Sim"],
/* Higher specificity selectors (ID selectors) */
#nd-sidebar
a[href="/"],
#nd-sidebar a[href="/"] img,
#nd-sidebar a[href="/"] svg,
#nd-sidebar > a:first-child,
#nd-sidebar > div:first-child > a:first-child,
#nd-sidebar img[alt="Sim"],
#nd-sidebar svg[aria-label="Sim"],
/* Hide theme toggle at bottom of sidebar on desktop */
#nd-sidebar
> footer,
@@ -515,6 +532,15 @@ pre code .line {
color: var(--color-fd-primary);
}
/* ============================================
TOC (Table of Contents) Styling
============================================ */
/* Remove the thin border-left on nested TOC items (keeps main indicator only) */
#nd-toc a[style*="padding-inline-start"] {
border-left: none !important;
}
/* Add bottom spacing to prevent abrupt page endings */
[data-content] {
padding-top: 1.5rem !important;

View File

@@ -44,7 +44,7 @@ export function SidebarItem({ item }: { item: Item }) {
'lg:text-gray-600 lg:dark:text-gray-400',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active &&
'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400'
'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
)}
>
{item.name}
@@ -79,7 +79,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
'lg:text-gray-600 lg:dark:text-gray-400',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active &&
'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400'
'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
)}
>
{item.name}
@@ -104,7 +104,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
'lg:text-gray-800 lg:dark:text-gray-200',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active &&
'lg:bg-purple-50/80 lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400'
'lg:bg-emerald-50/80 lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
)}
>
{item.name}

View File

@@ -23,7 +23,7 @@ export function TOCFooter() {
rel='noopener noreferrer'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] px-3 pr-[10px] pl-[12px] font-medium text-sm text-white shadow-[inset_0_2px_4px_0_#9B77FF] outline-none transition-all hover:shadow-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[10px] border border-[#2AAD6C] bg-gradient-to-b from-[#3ED990] to-[#2AAD6C] px-3 pr-[10px] pl-[12px] font-medium text-sm text-white shadow-[inset_0_2px_4px_0_#5EE8A8] outline-none transition-all hover:shadow-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
aria-label='Get started with Sim - Sign up for free'
>
<span>Get started</span>

View File

@@ -1,9 +1,9 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { LanguageDropdown } from '@/components/ui/language-dropdown'
import { SearchTrigger } from '@/components/ui/search-trigger'
import { SimLogoFull } from '@/components/ui/sim-logo'
import { ThemeToggle } from '@/components/ui/theme-toggle'
export function Navbar() {
@@ -27,13 +27,7 @@ export function Navbar() {
{/* Left cluster: logo */}
<div className='flex items-center'>
<Link href='/' className='flex min-w-[100px] items-center'>
<Image
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
/>
<SimLogoFull className='h-7 w-auto' />
</Link>
</div>

View File

@@ -1,38 +1,87 @@
'use client'
import { getAssetUrl } from '@/lib/utils'
import { useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
interface ActionImageProps {
src: string
alt: string
enableLightbox?: boolean
}
interface ActionVideoProps {
src: string
alt: string
enableLightbox?: boolean
}
export function ActionImage({ src, alt }: ActionImageProps) {
export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const handleClick = () => {
if (enableLightbox) {
setIsLightboxOpen(true)
}
}
return (
<img
src={src}
alt={alt}
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
/>
<>
<img
src={src}
alt={alt}
onClick={handleClick}
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
)}
/>
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='image'
/>
)}
</>
)
}
export function ActionVideo({ src, alt }: ActionVideoProps) {
export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const resolvedSrc = getAssetUrl(src)
const handleClick = () => {
if (enableLightbox) {
setIsLightboxOpen(true)
}
}
return (
<video
src={resolvedSrc}
autoPlay
loop
muted
playsInline
className='inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700'
/>
<>
<video
src={resolvedSrc}
autoPlay
loop
muted
playsInline
onClick={handleClick}
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
)}
/>
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='video'
/>
)}
</>
)
}

View File

@@ -1,8 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, ChevronRight } from 'lucide-react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
const languages = {
en: { name: 'English', flag: '🇺🇸' },
@@ -15,6 +16,7 @@ const languages = {
export function LanguageDropdown() {
const [isOpen, setIsOpen] = useState(false)
const [hoveredIndex, setHoveredIndex] = useState<number>(-1)
const pathname = usePathname()
const params = useParams()
const router = useRouter()
@@ -71,6 +73,15 @@ export function LanguageDropdown() {
return () => window.removeEventListener('keydown', onKey)
}, [isOpen])
// Reset hovered index when popover closes
useEffect(() => {
if (!isOpen) {
setHoveredIndex(-1)
}
}, [isOpen])
const languageEntries = Object.entries(languages)
return (
<div className='relative'>
<button
@@ -82,14 +93,14 @@ export function LanguageDropdown() {
aria-haspopup='listbox'
aria-expanded={isOpen}
aria-controls='language-menu'
className='flex cursor-pointer items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
className='flex cursor-pointer items-center gap-1.5 rounded-[6px] px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
>
<span>{languages[currentLang as keyof typeof languages]?.name}</span>
<ChevronRight className='h-3.5 w-3.5' />
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', isOpen && 'rotate-180')} />
</button>
{isOpen && (
@@ -98,29 +109,37 @@ export function LanguageDropdown() {
<div
id='language-menu'
role='listbox'
className='absolute top-full right-0 z-[1001] mt-1 max-h-[75vh] w-56 overflow-auto rounded-xl border border-border/50 bg-white shadow-2xl md:w-44 md:bg-background/95 md:backdrop-blur-md dark:bg-neutral-950 md:dark:bg-background/95'
className='absolute top-full right-0 z-[1001] mt-2 max-h-[400px] min-w-[160px] overflow-auto rounded-[6px] bg-white px-[6px] py-[6px] shadow-lg dark:bg-neutral-900'
>
{Object.entries(languages).map(([code, lang]) => (
<button
key={code}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleLanguageChange(code)
}}
role='option'
aria-selected={currentLang === code}
className={`flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
currentLang === code ? 'bg-muted/60 font-medium text-primary' : 'text-foreground'
}`}
>
<span className='text-base md:text-sm'>{lang.flag}</span>
<span className='leading-none'>{lang.name}</span>
{currentLang === code && (
<Check className='ml-auto h-4 w-4 text-primary md:h-3.5 md:w-3.5' />
)}
</button>
))}
{languageEntries.map(([code, lang], index) => {
const isSelected = currentLang === code
const isHovered = hoveredIndex === index
return (
<button
key={code}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleLanguageChange(code)
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
role='option'
aria-selected={isSelected}
className={cn(
'flex h-[26px] w-full min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] text-[13px] transition-colors',
'text-neutral-700 dark:text-neutral-200',
isHovered && 'bg-neutral-100 dark:bg-neutral-800',
'focus:outline-none'
)}
>
<span className='text-[13px]'>{lang.flag}</span>
<span className='flex-1 text-left leading-none'>{lang.name}</span>
{isSelected && <Check className='ml-auto h-3.5 w-3.5' />}
</button>
)
})}
</div>
</>
)}

View File

@@ -0,0 +1,108 @@
'use client'
import { cn } from '@/lib/utils'
interface SimLogoProps {
className?: string
}
/**
* Sim logo with icon and text.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogo({ className }: SimLogoProps) {
return (
<svg
viewBox='720 440 320 320'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('h-7 w-auto', className)}
aria-label='Sim'
>
{/* Green icon - top left shape with cutout */}
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
{/* Green icon - bottom right square */}
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
{/* Gradient overlay on bottom right square */}
<path
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
fill='url(#sim-logo-gradient)'
fillOpacity='0.2'
/>
<defs>
<linearGradient
id='sim-logo-gradient'
x1='904.414'
y1='624.199'
x2='978.836'
y2='698.447'
gradientUnits='userSpaceOnUse'
>
<stop />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
)
}
/**
* Full Sim logo with icon and "Sim" text.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogoFull({ className }: SimLogoProps) {
return (
<svg
viewBox='720 440 1020 320'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('h-7 w-auto', className)}
aria-label='Sim'
>
{/* Green icon - top left shape with cutout */}
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
{/* Green icon - bottom right square */}
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
{/* Gradient overlay on bottom right square */}
<path
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
fill='url(#sim-logo-full-gradient)'
fillOpacity='0.2'
/>
{/* "Sim" text - adapts to light/dark mode via currentColor */}
<path
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
className='fill-neutral-900 dark:fill-white'
/>
<defs>
<linearGradient
id='sim-logo-full-gradient'
x1='904.414'
y1='624.199'
x2='978.836'
y2='698.447'
gradientUnits='userSpaceOnUse'
>
<stop />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
)
}

View File

@@ -17,7 +17,7 @@ MCP-Server gruppieren Ihre Workflow-Tools zusammen. Erstellen und verwalten Sie
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs**
1. Navigieren Sie zu **Einstellungen → MCP-Server**
2. Klicken Sie auf **Server erstellen**
3. Geben Sie einen Namen und eine optionale Beschreibung ein
4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients
@@ -79,7 +79,7 @@ Füge deinen API-Key-Header (`X-API-Key`) für authentifizierten Zugriff hinzu,
## Server-Verwaltung
In der Server-Detailansicht unter **Einstellungen → Bereitgestellte MCPs** können Sie:
In der Server-Detailansicht unter **Einstellungen → MCP-Server** können Sie:
- **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden
- **URL kopieren**: Die Server-URL für MCP-Clients abrufen

View File

@@ -27,7 +27,7 @@ MCP-Server stellen Sammlungen von Tools bereit, die Ihre Agenten nutzen können.
</div>
1. Navigieren Sie zu Ihren Workspace-Einstellungen
2. Gehen Sie zum Abschnitt **Bereitgestellte MCPs**
2. Gehen Sie zum Abschnitt **MCP-Server**
3. Klicken Sie auf **MCP-Server hinzufügen**
4. Geben Sie die Server-Konfigurationsdetails ein
5. Speichern Sie die Konfiguration

View File

@@ -56,6 +56,10 @@ Controls response randomness and creativity:
- **Medium (0.3-0.7)**: Balanced creativity and focus. Good for general use.
- **High (0.7-2.0)**: Creative and varied. Ideal for brainstorming and content generation.
### Max Output Tokens
Controls the maximum length of the model's response. For Anthropic models, Sim uses reliable defaults: streaming executions use the model's full capacity (e.g. 64,000 tokens for Claude 4.5), while non-streaming executions default to 8,192 to avoid timeout issues. For long-form content generation via API, explicitly set a higher value.
### API Key
Your API key for the selected LLM provider. This is securely stored and used for authentication.

View File

@@ -16,7 +16,7 @@ MCP servers group your workflow tools together. Create and manage them in worksp
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navigate to **Settings → Deployed MCPs**
1. Navigate to **Settings → MCP Servers**
2. Click **Create Server**
3. Enter a name and optional description
4. Copy the server URL for use in your MCP clients
@@ -78,7 +78,7 @@ Include your API key header (`X-API-Key`) for authenticated access when using mc
## Server Management
From the server detail view in **Settings → Deployed MCPs**, you can:
From the server detail view in **Settings → MCP Servers**, you can:
- **View tools**: See all workflows added to a server
- **Copy URL**: Get the server URL for MCP clients

View File

@@ -27,7 +27,7 @@ MCP servers provide collections of tools that your agents can use. Configure the
</div>
1. Navigate to your workspace settings
2. Go to the **Deployed MCPs** section
2. Go to the **MCP Servers** section
3. Click **Add MCP Server**
4. Enter the server configuration details
5. Save the configuration

View File

@@ -17,7 +17,7 @@ Los servidores MCP agrupan tus herramientas de flujo de trabajo. Créalos y gest
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navega a **Configuración → MCP implementados**
1. Navega a **Configuración → Servidores MCP**
2. Haz clic en **Crear servidor**
3. Introduce un nombre y una descripción opcional
4. Copia la URL del servidor para usarla en tus clientes MCP
@@ -79,7 +79,7 @@ Incluye tu encabezado de clave API (`X-API-Key`) para acceso autenticado al usar
## Gestión del servidor
Desde la vista de detalles del servidor en **Configuración → MCP implementados**, puedes:
Desde la vista de detalles del servidor en **Configuración → Servidores MCP**, puedes:
- **Ver herramientas**: consulta todos los flujos de trabajo añadidos a un servidor
- **Copiar URL**: obtén la URL del servidor para clientes MCP

View File

@@ -27,7 +27,7 @@ Los servidores MCP proporcionan colecciones de herramientas que tus agentes pued
</div>
1. Navega a la configuración de tu espacio de trabajo
2. Ve a la sección **MCP implementados**
2. Ve a la sección **Servidores MCP**
3. Haz clic en **Añadir servidor MCP**
4. Introduce los detalles de configuración del servidor
5. Guarda la configuración

View File

@@ -17,7 +17,7 @@ Les serveurs MCP regroupent vos outils de workflow. Créez-les et gérez-les dan
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Accédez à **Paramètres → MCP déployés**
1. Accédez à **Paramètres → Serveurs MCP**
2. Cliquez sur **Créer un serveur**
3. Saisissez un nom et une description facultative
4. Copiez l'URL du serveur pour l'utiliser dans vos clients MCP
@@ -79,7 +79,7 @@ Incluez votre en-tête de clé API (`X-API-Key`) pour un accès authentifié lor
## Gestion du serveur
Depuis la vue détaillée du serveur dans **Paramètres → MCP déployés**, vous pouvez :
Depuis la vue détaillée du serveur dans **Paramètres → Serveurs MCP**, vous pouvez :
- **Voir les outils** : voir tous les workflows ajoutés à un serveur
- **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP

View File

@@ -28,7 +28,7 @@ Les serveurs MCP fournissent des collections d'outils que vos agents peuvent uti
</div>
1. Accédez aux paramètres de votre espace de travail
2. Allez dans la section **MCP déployés**
2. Allez dans la section **Serveurs MCP**
3. Cliquez sur **Ajouter un serveur MCP**
4. Saisissez les détails de configuration du serveur
5. Enregistrez la configuration

View File

@@ -16,7 +16,7 @@ MCPサーバーは、ワークフローツールをまとめてグループ化
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. **設定 → デプロイ済みMCP**に移動します
1. **設定 → MCP サーバー**に移動します
2. **サーバーを作成**をクリックします
3. 名前とオプションの説明を入力します
4. MCPクライアントで使用するためにサーバーURLをコピーします
@@ -78,7 +78,7 @@ mcp-remoteまたは他のHTTPベースのMCPトランスポートを使用する
## サーバー管理
**設定 → デプロイ済みMCP**のサーバー詳細ビューから、次のことができます:
**設定 → MCP サーバー**のサーバー詳細ビューから、次のことができます:
- **ツールを表示**: サーバーに追加されたすべてのワークフローを確認
- **URLをコピー**: MCPクライアント用のサーバーURLを取得

View File

@@ -27,7 +27,7 @@ MCPサーバーはエージェントが使用できるツールのコレクシ
</div>
1. ワークスペース設定に移動します
2. **デプロイ済みMCP**セクションに移動します
2. **MCP サーバー**セクションに移動します
3. **MCPサーバーを追加**をクリックします
4. サーバー設定の詳細を入力します
5. 設定を保存します

View File

@@ -16,7 +16,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. 进入 **设置 → 已部署的 MCPs**
1. 进入 **设置 → MCP 服务器**
2. 点击 **创建服务器**
3. 输入名称和可选描述
4. 复制服务器 URL 以在你的 MCP 客户端中使用
@@ -78,7 +78,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
## 服务器管理
在 **设置 → 已部署的 MCPs** 的服务器详情页,你可以:
在 **设置 → MCP 服务器** 的服务器详情页,你可以:
- **查看工具**:查看添加到服务器的所有工作流
- **复制 URL**:获取 MCP 客户端的服务器 URL

View File

@@ -27,7 +27,7 @@ MCP 服务器提供工具集合,供您的代理使用。您可以在工作区
</div>
1. 进入您的工作区设置
2. 前往 **Deployed MCPs** 部分
2. 前往 **MCP Servers** 部分
3. 点击 **Add MCP Server**
4. 输入服务器配置信息
5. 保存配置

View File

@@ -31,7 +31,6 @@ const DEFAULT_ENABLED_MODELS: Record<CopilotModelId, boolean> = {
'claude-4.5-opus': true,
'claude-4.1-opus': false,
'gemini-3-pro': true,
'auto': true,
}
// GET - Fetch user's enabled models

View File

@@ -16,6 +16,10 @@ mockKnowledgeSchemas()
mockDrizzleOrm()
mockConsoleLogger()
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue({ role: 'owner' }),
}))
describe('Knowledge Base API Route', () => {
const mockAuth$ = mockAuth()
@@ -86,6 +90,7 @@ describe('Knowledge Base API Route', () => {
const validKnowledgeBaseData = {
name: 'Test Knowledge Base',
description: 'Test description',
workspaceId: 'test-workspace-id',
chunkingConfig: {
maxSize: 1024,
minSize: 100,
@@ -133,11 +138,25 @@ describe('Knowledge Base API Route', () => {
expect(data.details).toBeDefined()
})
it('should require workspaceId', async () => {
mockAuth$.mockAuthenticatedUser()
const req = createMockRequest('POST', { name: 'Test KB' })
const { POST } = await import('@/app/api/knowledge/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request data')
expect(data.details).toBeDefined()
})
it('should validate chunking config constraints', async () => {
mockAuth$.mockAuthenticatedUser()
const invalidData = {
name: 'Test KB',
workspaceId: 'test-workspace-id',
chunkingConfig: {
maxSize: 100, // 100 tokens = 400 characters
minSize: 500, // Invalid: minSize (500 chars) > maxSize (400 chars)
@@ -157,7 +176,7 @@ describe('Knowledge Base API Route', () => {
it('should use default values for optional fields', async () => {
mockAuth$.mockAuthenticatedUser()
const minimalData = { name: 'Test KB' }
const minimalData = { name: 'Test KB', workspaceId: 'test-workspace-id' }
const req = createMockRequest('POST', minimalData)
const { POST } = await import('@/app/api/knowledge/route')
const response = await POST(req)

View File

@@ -19,7 +19,7 @@ const logger = createLogger('KnowledgeBaseAPI')
const CreateKnowledgeBaseSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
workspaceId: z.string().optional(),
workspaceId: z.string().min(1, 'Workspace ID is required'),
embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'),
embeddingDimension: z.literal(1536).default(1536),
chunkingConfig: z

View File

@@ -1,377 +0,0 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { DAGExecutor } from '@/executor/execution/executor'
import type { IterationContext, SerializableExecutionState } from '@/executor/execution/types'
import type { NormalizedBlockOutput } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('ExecuteFromBlockAPI')
const ExecuteFromBlockSchema = z.object({
startBlockId: z.string().min(1, 'Start block ID is required'),
sourceSnapshot: z.object({
blockStates: z.record(z.any()),
executedBlocks: z.array(z.string()),
blockLogs: z.array(z.any()),
decisions: z.object({
router: z.record(z.string()),
condition: z.record(z.string()),
}),
completedLoops: z.array(z.string()),
loopExecutions: z.record(z.any()).optional(),
parallelExecutions: z.record(z.any()).optional(),
parallelBlockMapping: z.record(z.any()).optional(),
activeExecutionPath: z.array(z.string()),
}),
})
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/**
* POST /api/workflows/[id]/execute-from-block
*
* Executes a workflow starting from a specific block using cached outputs
* for upstream/unaffected blocks from the source snapshot.
*/
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id: workflowId } = await params
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const validation = ExecuteFromBlockSchema.safeParse(body)
if (!validation.success) {
logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors)
return NextResponse.json(
{
error: 'Invalid request body',
details: validation.error.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
)
}
const { startBlockId, sourceSnapshot } = validation.data
logger.info(`[${requestId}] Starting run-from-block execution`, {
workflowId,
userId,
startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length,
})
const executionId = uuidv4()
// Load workflow record to get workspaceId
const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
}
const workspaceId = workflowRecord.workspaceId
// Load workflow state
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
if (!workflowData) {
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
}
const { blocks, edges, loops, parallels } = workflowData
// Merge block states
const mergedStates = mergeSubblockState(blocks)
// Get environment variables
const { personalDecrypted, workspaceDecrypted } = await getPersonalAndWorkspaceEnv(
userId,
workspaceId
)
const decryptedEnvVars: Record<string, string> = { ...personalDecrypted, ...workspaceDecrypted }
// Serialize workflow
const serializedWorkflow = new Serializer().serializeWorkflow(
mergedStates,
edges,
loops,
parallels,
true
)
const encoder = new TextEncoder()
const abortController = new AbortController()
let isStreamClosed = false
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const sendEvent = (event: ExecutionEvent) => {
if (isStreamClosed) return
try {
controller.enqueue(encodeSSEEvent(event))
} catch {
isStreamClosed = true
}
}
try {
const startTime = new Date()
sendEvent({
type: 'execution:started',
timestamp: startTime.toISOString(),
executionId,
workflowId,
data: {
startTime: startTime.toISOString(),
},
})
const onBlockStart = async (
blockId: string,
blockName: string,
blockType: string,
iterationContext?: IterationContext
) => {
sendEvent({
type: 'block:started',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
blockName,
blockType,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
}),
},
})
}
const onBlockComplete = async (
blockId: string,
blockName: string,
blockType: string,
callbackData: { input?: unknown; output: NormalizedBlockOutput; executionTime: number },
iterationContext?: IterationContext
) => {
const hasError = (callbackData.output as any)?.error
if (hasError) {
sendEvent({
type: 'block:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
blockName,
blockType,
input: callbackData.input,
error: (callbackData.output as any).error,
durationMs: callbackData.executionTime || 0,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
}),
},
})
} else {
sendEvent({
type: 'block:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
blockName,
blockType,
input: callbackData.input,
output: callbackData.output,
durationMs: callbackData.executionTime || 0,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
}),
},
})
}
}
const onStream = async (streamingExecution: unknown) => {
const streamingExec = streamingExecution as { stream: ReadableStream; execution: any }
const blockId = streamingExec.execution?.blockId
const reader = streamingExec.stream.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
sendEvent({
type: 'stream:chunk',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { blockId, chunk },
})
}
sendEvent({
type: 'stream:done',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { blockId },
})
} catch (error) {
logger.error(`[${requestId}] Error streaming block content:`, error)
} finally {
try {
reader.releaseLock()
} catch {}
}
}
// Create executor and run from block
const executor = new DAGExecutor({
workflow: serializedWorkflow,
envVarValues: decryptedEnvVars,
workflowInput: {},
workflowVariables: {},
contextExtensions: {
stream: true,
executionId,
workspaceId,
userId,
isDeployedContext: false,
onBlockStart,
onBlockComplete,
onStream,
abortSignal: abortController.signal,
},
})
const result = await executor.executeFromBlock(
workflowId,
startBlockId,
sourceSnapshot as SerializableExecutionState
)
if (result.status === 'cancelled') {
sendEvent({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
},
})
return
}
sendEvent({
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
success: result.success,
output: result.output,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
},
})
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: executionResult?.error || errorMessage,
duration: executionResult?.metadata?.duration || 0,
},
})
} finally {
if (!isStreamClosed) {
try {
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
} catch {
// Stream already closed
}
}
}
},
cancel() {
isStreamClosed = true
logger.info(`[${requestId}] Client aborted SSE stream, signalling cancellation`)
abortController.abort()
markExecutionCancelled(executionId).catch(() => {})
},
})
return new NextResponse(stream, {
headers: {
...SSE_HEADERS,
'X-Execution-Id': executionId,
},
})
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Failed to start run-from-block execution:`, error)
return NextResponse.json(
{ error: errorMessage || 'Failed to start execution' },
{ status: 500 }
)
}
}

View File

@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
@@ -330,7 +330,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
try {
return (
<WorkflowPreview
<PreviewWorkflow
workflowState={template.state}
height='100%'
width='100%'

View File

@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -200,13 +200,14 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview
<PreviewWorkflow
workflowState={normalizedState}
height={180}
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
) : (
<div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
@@ -13,13 +13,8 @@ import {
PopoverContent,
PopoverItem,
} from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -32,13 +27,6 @@ interface TraceSpan {
children?: TraceSpan[]
}
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
}
interface MigratedWorkflowState extends WorkflowState {
_migrated: true
_note?: string
@@ -70,98 +58,35 @@ export function ExecutionSnapshot({
onClose = () => {},
}: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const autoSelectedForExecutionRef = useRef<string | null>(null)
const lastExecutionIdRef = useRef<string | null>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => {
setIsMenuOpen(false)
setContextMenuBlockId(null)
}, [])
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuBlockId(null)
setMenuPosition({ x: e.clientX, y: e.clientY })
setIsMenuOpen(true)
}, [])
const handleNodeContextMenu = useCallback(
(blockId: string, mousePosition: { x: number; y: number }) => {
setContextMenuBlockId(blockId)
setMenuPosition(mousePosition)
setIsMenuOpen(true)
},
[]
)
const handleCopyExecutionId = useCallback(() => {
navigator.clipboard.writeText(executionId)
closeMenu()
}, [executionId, closeMenu])
const handleOpenDetails = useCallback(() => {
if (contextMenuBlockId) {
setPinnedBlockId(contextMenuBlockId)
}
closeMenu()
}, [contextMenuBlockId, closeMenu])
const blockExecutions = useMemo(() => {
if (!traceSpans || !Array.isArray(traceSpans)) return {}
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of spans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
}
}
}
return blockExecutionMap
}, [traceSpans])
const workflowState = data?.workflowState as WorkflowState | undefined
// Auto-select the leftmost block once when data loads for a new executionId
useEffect(() => {
if (
workflowState &&
!isMigratedWorkflowState(workflowState) &&
autoSelectedForExecutionRef.current !== executionId
) {
autoSelectedForExecutionRef.current = executionId
const leftmostId = getLeftmostBlockId(workflowState)
setPinnedBlockId(leftmostId)
}
}, [executionId, workflowState])
// Track execution ID changes for key reset
const executionKey = executionId !== lastExecutionIdRef.current ? executionId : undefined
if (executionId !== lastExecutionIdRef.current) {
lastExecutionIdRef.current = executionId
}
const renderContent = () => {
if (isLoading) {
@@ -226,44 +151,17 @@ export function ExecutionSnapshot({
}
return (
<div
style={{ height, width }}
className={cn(
'flex overflow-hidden',
!isModal && 'rounded-[4px] border border-[var(--border)]',
className
)}
>
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
<WorkflowPreview
workflowState={workflowState}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId(blockId)
}}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={() => setPinnedBlockId(null)}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode
onClose={() => setPinnedBlockId(null)}
/>
)}
</div>
<Preview
key={executionKey}
workflowState={workflowState}
traceSpans={traceSpans}
className={className}
height={height}
width={width}
onCanvasContextMenu={handleCanvasContextMenu}
showBorder={!isModal}
autoSelectLeftmost
/>
)
}
@@ -287,9 +185,6 @@ export function ExecutionSnapshot({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{contextMenuBlockId && (
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
)}
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
</PopoverContent>
</Popover>,
@@ -304,7 +199,6 @@ export function ExecutionSnapshot({
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setPinnedBlockId(null)
onClose()
}
}}

View File

@@ -3,7 +3,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -206,7 +206,7 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview
<PreviewWorkflow
workflowState={normalizedState}
height={180}
width='100%'
@@ -214,6 +214,7 @@ function TemplateCardInner({
defaultZoom={0.8}
fitPadding={0.2}
cursorStyle='pointer'
lightweight
/>
) : (
<div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />

View File

@@ -1,12 +1,11 @@
import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut, Play } from 'lucide-react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -98,42 +97,12 @@ export const ActionBar = memo(
)
)
const { activeWorkflowId } = useWorkflowRegistry()
const { isExecuting, getLastExecutionSnapshot } = useExecutionStore()
const userPermissions = useUserPermissionsContext()
const isStartBlock = isInputDefinitionTrigger(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
// Check if run-from-block is available
const hasExecutionSnapshot = activeWorkflowId
? !!getLastExecutionSnapshot(activeWorkflowId)
: false
const wasExecuted = activeWorkflowId
? getLastExecutionSnapshot(activeWorkflowId)?.executedBlocks.includes(blockId) ?? false
: false
const canRunFromBlock =
hasExecutionSnapshot &&
wasExecuted &&
!isStartBlock &&
!isNoteBlock &&
!isSubflowBlock &&
!isInsideSubflow &&
!isExecuting
const handleRunFromBlock = useCallback(() => {
if (!activeWorkflowId || !canRunFromBlock) return
// Dispatch a custom event to trigger run-from-block execution
window.dispatchEvent(
new CustomEvent('run-from-block', {
detail: { blockId, workflowId: activeWorkflowId },
})
)
}, [blockId, activeWorkflowId, canRunFromBlock])
/**
* Get appropriate tooltip message based on disabled state
@@ -182,7 +151,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{canRunFromBlock && (
{isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -190,17 +159,17 @@ export const ActionBar = memo(
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
handleRunFromBlock()
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || isExecuting}
disabled={disabled}
>
<Play className={ICON_SIZE} />
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isExecuting ? 'Execution in progress' : getTooltipMessage('Run from this block')}
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}

View File

@@ -25,18 +25,277 @@ function extractFieldValue(rawValue: unknown): string | undefined {
return undefined
}
type EmbedInfo = {
url: string
type: 'iframe' | 'video' | 'audio'
aspectRatio?: string
}
const EMBED_SCALE = 0.78
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
function getTwitchParent(): string {
return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
}
/**
* Extract YouTube video ID from various YouTube URL formats
* Get embed info for supported media platforms
*/
function getYouTubeVideoId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
function getEmbedInfo(url: string): EmbedInfo | null {
const youtubeMatch = url.match(
/(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
)
if (youtubeMatch) {
return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' }
}
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
if (vimeoMatch) {
return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
}
const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
if (dailymotionMatch) {
return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
}
const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
if (twitchVideoMatch) {
return {
url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
return {
url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
if (streamableMatch) {
return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
}
const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
if (wistiaMatch) {
return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
}
const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
if (tiktokMatch) {
return {
url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
type: 'iframe',
aspectRatio: '9/16',
}
}
const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
if (soundcloudMatch) {
return {
url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
type: 'iframe',
aspectRatio: '3/2',
}
}
const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
if (spotifyTrackMatch) {
return {
url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
type: 'iframe',
aspectRatio: '3.7/1',
}
}
const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
if (spotifyAlbumMatch) {
return {
url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
if (spotifyPlaylistMatch) {
return {
url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
if (spotifyEpisodeMatch) {
return {
url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
type: 'iframe',
aspectRatio: '2.5/1',
}
}
const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
if (spotifyShowMatch) {
return {
url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
type: 'iframe',
aspectRatio: '3.7/1',
}
}
const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
if (appleMusicSongMatch) {
const [, country, songId] = appleMusicSongMatch
return {
url: `https://embed.music.apple.com/${country}/song/${songId}`,
type: 'iframe',
aspectRatio: '3/2',
}
}
const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
if (appleMusicAlbumMatch) {
const [, country, albumId] = appleMusicAlbumMatch
return {
url: `https://embed.music.apple.com/${country}/album/${albumId}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const appleMusicPlaylistMatch = url.match(
/music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
)
if (appleMusicPlaylistMatch) {
const [, country, playlistId] = appleMusicPlaylistMatch
return {
url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
if (loomMatch) {
return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
}
const facebookVideoMatch =
url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
if (facebookVideoMatch) {
return {
url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
type: 'iframe',
}
}
const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
if (instagramReelMatch) {
return {
url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
type: 'iframe',
aspectRatio: '9/16',
}
}
const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
if (instagramPostMatch) {
return {
url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
type: 'iframe',
aspectRatio: '4/5',
}
}
const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
if (twitterMatch) {
return {
url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
type: 'iframe',
aspectRatio: '3/4',
}
}
const rumbleMatch =
url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
if (rumbleMatch) {
return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
}
const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
if (bilibiliMatch) {
return {
url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
type: 'iframe',
}
}
const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
if (vidyardMatch) {
return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
}
const cfStreamMatch =
url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
if (cfStreamMatch) {
return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
}
const twitchClipMatch =
url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
if (twitchClipMatch) {
return {
url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
if (mixcloudMatch) {
return {
url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
type: 'iframe',
aspectRatio: '2/1',
}
}
const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
if (googleDriveMatch) {
return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
}
if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
const directUrl = url
.replace('www.dropbox.com', 'dl.dropboxusercontent.com')
.replace('?dl=0', '')
return { url: directUrl, type: 'video' }
}
const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
if (tenorMatch) {
return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
}
const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
if (giphyMatch) {
return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
}
if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
return { url, type: 'video' }
}
if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
return { url, type: 'audio' }
}
return null
}
@@ -108,29 +367,57 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
)
},
a: ({ href, children }: any) => {
const videoId = href ? getYouTubeVideoId(href) : null
if (videoId) {
const embedInfo = href ? getEmbedInfo(href) : null
if (embedInfo) {
return (
<span className='inline'>
<span className='my-2 block w-full'>
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='text-[var(--brand-secondary)] underline-offset-2 hover:underline'
className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
<span className='mt-1.5 block overflow-hidden rounded-md'>
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
title='YouTube video'
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
loading='lazy'
referrerPolicy='strict-origin-when-cross-origin'
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
className='aspect-video w-full'
/>
<span className='block w-full overflow-hidden rounded-md'>
{embedInfo.type === 'iframe' && (
<span
className='block overflow-hidden'
style={{
width: '100%',
aspectRatio: embedInfo.aspectRatio || '16/9',
}}
>
<iframe
src={embedInfo.url}
title='Media'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
loading='lazy'
className='origin-top-left'
style={{
width: EMBED_INVERSE_SCALE,
height: EMBED_INVERSE_SCALE,
transform: `scale(${EMBED_SCALE})`,
}}
/>
</span>
)}
{embedInfo.type === 'video' && (
<video
src={embedInfo.url}
controls
preload='metadata'
className='aspect-video w-full'
>
<track kind='captions' src='' default />
</video>
)}
{embedInfo.type === 'audio' && (
<audio src={embedInfo.url} controls preload='metadata' className='w-full'>
<track kind='captions' src='' default />
</audio>
)}
</span>
</span>
)
@@ -140,7 +427,7 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
href={href}
target='_blank'
rel='noopener noreferrer'
className='text-[var(--brand-secondary)] underline-offset-2 hover:underline'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
@@ -159,6 +446,26 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
{children}
</blockquote>
),
table: ({ children }: any) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='w-full border-collapse text-xs'>{children}</table>
</div>
),
thead: ({ children }: any) => (
<thead className='border-[var(--border)] border-b'>{children}</thead>
),
tbody: ({ children }: any) => <tbody>{children}</tbody>,
tr: ({ children }: any) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: any) => (
<th className='px-2 py-1 text-left font-semibold text-[var(--text-primary)]'>
{children}
</th>
),
td: ({ children }: any) => (
<td className='px-2 py-1 text-[var(--text-secondary)]'>{children}</td>
),
}}
>
{content}
@@ -171,7 +478,7 @@ export const NoteBlock = memo(function NoteBlock({
data,
selected,
}: NodeProps<NoteBlockNodeData>) {
const { type, config, name } = data
const { type, name } = data
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
blockId: id,
@@ -248,8 +555,8 @@ export const NoteBlock = memo(function NoteBlock({
</div>
</div>
<div className='relative p-[8px]'>
<div className='relative break-words'>
<div className='relative overflow-hidden p-[8px]'>
<div className='relative max-w-full break-all'>
{isEmpty ? (
<p className='text-[#868686] text-sm'>Add note...</p>
) : (

View File

@@ -246,7 +246,6 @@ export function getCommandDisplayLabel(commandId: string): string {
* Model configuration options
*/
export const MODEL_OPTIONS = [
{ value: 'auto', label: 'Auto' },
{ value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' },
{ value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' },
{ value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' },

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -17,11 +17,7 @@ import {
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { Versions } from './components'
@@ -59,8 +55,6 @@ export function GeneralDeploy({
const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
const hasAutoSelectedRef = useRef(false)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
@@ -135,19 +129,6 @@ export function GeneralDeploy({
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
// Auto-select the leftmost block once when expanded preview opens
useEffect(() => {
if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true
const leftmostId = getLeftmostBlockId(workflowToShow)
setExpandedSelectedBlockId(leftmostId)
}
// Reset when modal closes
if (!showExpandedPreview) {
hasAutoSelectedRef.current = false
}
}, [showExpandedPreview, workflowToShow])
if (showLoadingSkeleton) {
return (
<div className='space-y-[12px]'>
@@ -205,7 +186,7 @@ export function GeneralDeploy({
{workflowToShow ? (
<>
<div className='[&_*]:!cursor-default h-full w-full cursor-default'>
<WorkflowPreview
<PreviewWorkflow
workflowState={workflowToShow}
height='100%'
width='100%'
@@ -306,46 +287,15 @@ export function GeneralDeploy({
</Modal>
{workflowToShow && (
<Modal
open={showExpandedPreview}
onOpenChange={(open) => {
if (!open) {
setExpandedSelectedBlockId(null)
}
setShowExpandedPreview(open)
}}
>
<Modal open={showExpandedPreview} onOpenChange={setShowExpandedPreview}>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>
{previewMode === 'selected' && selectedVersionInfo
? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'}
</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'>
<div className='flex h-full w-full overflow-hidden'>
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowToShow}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
onNodeClick={(blockId) => {
setExpandedSelectedBlockId(blockId)
}}
onPaneClick={() => setExpandedSelectedBlockId(null)}
selectedBlockId={expandedSelectedBlockId}
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<PreviewEditor
block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops}
parallels={workflowToShow.parallels}
onClose={() => setExpandedSelectedBlockId(null)}
/>
)}
</div>
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>
<Preview workflowState={workflowToShow} autoSelectLeftmost />
</ModalBody>
</ModalContent>
</Modal>

View File

@@ -435,7 +435,7 @@ export function McpDeploy({
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings Deployed MCPs first.
Create an MCP Server in Settings MCP Servers first.
</p>
<Button
variant='tertiary'

View File

@@ -19,7 +19,7 @@ import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import {
useCreateTemplate,
@@ -439,13 +439,14 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
}}
aria-hidden='true'
>
<WorkflowPreview
<PreviewWorkflow
workflowState={workflowState}
height='100%'
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)
@@ -477,7 +478,7 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
}
return (
<WorkflowPreview
<PreviewWorkflow
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
height='100%'

View File

@@ -39,6 +39,8 @@ import { normalizeName } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useCodeUndoRedo } from '@/hooks/use-code-undo-redo'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Code')
@@ -212,7 +214,6 @@ export const Code = memo(function Code({
const handleStreamStartRef = useRef<() => void>(() => {})
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
const hasEditedSinceFocusRef = useRef(false)
const codeRef = useRef(code)
codeRef.current = code
@@ -220,8 +221,12 @@ export const Code = memo(function Code({
const emitTagSelection = useTagSelection(blockId, subBlockId)
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const blockType = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
)
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
const isFunctionCode = blockType === 'function' && subBlockId === 'code'
const trimmedCode = code.trim()
const containsReferencePlaceholders =
@@ -296,6 +301,15 @@ export const Code = memo(function Code({
const updatePromptValue = wandHook?.updatePromptValue || (() => {})
const cancelGeneration = wandHook?.cancelGeneration || (() => {})
const { recordChange, recordReplace, flushPending, startSession, undo, redo } = useCodeUndoRedo({
blockId,
subBlockId,
value: code,
enabled: isFunctionCode,
isReadOnly: readOnly || disabled || isPreview,
isStreaming: isAiStreaming,
})
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, {
isStreaming: isAiStreaming,
onStreamingEnd: () => {
@@ -347,9 +361,10 @@ export const Code = memo(function Code({
setCode(generatedCode)
if (!isPreview && !disabled) {
setStoreValue(generatedCode)
recordReplace(generatedCode)
}
}
}, [isPreview, disabled, setStoreValue])
}, [disabled, isPreview, recordReplace, setStoreValue])
useEffect(() => {
if (!editorRef.current) return
@@ -492,7 +507,7 @@ export const Code = memo(function Code({
setCode(newValue)
setStoreValue(newValue)
hasEditedSinceFocusRef.current = true
recordChange(newValue)
const newCursorPosition = dropPosition + 1
setCursorPosition(newCursorPosition)
@@ -521,7 +536,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true
recordChange(newValue)
}
setShowTags(false)
setActiveSourceBlockId(null)
@@ -539,7 +554,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true
recordChange(newValue)
}
setShowEnvVars(false)
@@ -625,9 +640,9 @@ export const Code = memo(function Code({
const handleValueChange = useCallback(
(newCode: string) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode)
setStoreValue(newCode)
recordChange(newCode)
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
@@ -646,7 +661,7 @@ export const Code = memo(function Code({
}
}
},
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
[isAiStreaming, isPreview, disabled, readOnly, recordChange, setStoreValue]
)
const handleKeyDown = useCallback(
@@ -657,21 +672,39 @@ export const Code = memo(function Code({
}
if (isAiStreaming) {
e.preventDefault()
return
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
if (!isFunctionCode) return
const isUndo = (e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && !e.shiftKey
const isRedo =
((e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && e.shiftKey) ||
(e.key === 'y' && (e.metaKey || e.ctrlKey))
if (isUndo) {
e.preventDefault()
e.stopPropagation()
undo()
return
}
if (isRedo) {
e.preventDefault()
e.stopPropagation()
redo()
}
},
[isAiStreaming]
[isAiStreaming, isFunctionCode, redo, undo]
)
const handleEditorFocus = useCallback(() => {
hasEditedSinceFocusRef.current = false
startSession(codeRef.current)
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
setShowTags(true)
setCursorPosition(0)
}
}, [isPreview, disabled, readOnly])
}, [disabled, isPreview, readOnly, startSession])
const handleEditorBlur = useCallback(() => {
flushPending()
}, [flushPending])
/**
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
@@ -791,6 +824,7 @@ export const Code = memo(function Code({
onValueChange={handleValueChange}
onKeyDown={handleKeyDown}
onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
highlight={highlightCode}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
/>

View File

@@ -38,7 +38,7 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
import { useWorkflowState } from '@/hooks/queries/workflows'
@@ -458,7 +458,7 @@ export function Editor() {
) : childWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<WorkflowPreview
<PreviewWorkflow
workflowState={childWorkflowState}
height={160}
width='100%'
@@ -466,6 +466,7 @@ export function Editor() {
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
lightweight
/>
</div>
<Tooltip.Root>

View File

@@ -268,6 +268,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
onMatchCountChange={onMatchCountChange}
contentRef={contentRef}
virtualized
showCollapseColumn={language === 'json'}
/>
)
})

View File

@@ -6,6 +6,7 @@ import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useExecutionStore } from '@/stores/execution'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
/** Extended edge props with optional handle identifiers */
interface WorkflowEdgeProps extends EdgeProps {
sourceHandle?: string | null
targetHandle?: string | null
@@ -90,15 +91,17 @@ const WorkflowEdgeComponent = ({
if (edgeDiffStatus === 'deleted') {
color = 'var(--text-error)'
opacity = 0.7
} else if (isErrorEdge) {
color = 'var(--text-error)'
} else if (edgeDiffStatus === 'new') {
color = 'var(--brand-tertiary-2)'
} else if (edgeRunStatus === 'success') {
// Use green for preview mode, default for canvas execution
// This also applies to error edges that were taken (error path executed)
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
} else if (edgeRunStatus === 'error') {
color = 'var(--text-error)'
} else if (isErrorEdge) {
// Error edges that weren't taken stay red
color = 'var(--text-error)'
}
if (isSelected) {
@@ -151,4 +154,14 @@ const WorkflowEdgeComponent = ({
)
}
/**
* Workflow edge component with execution status and diff visualization.
*
* @remarks
* Edge coloring priority:
* 1. Diff status (deleted/new) - for version comparison
* 2. Execution status (success/error) - for run visualization
* 3. Error edge default (red) - for untaken error paths
* 4. Default edge color - normal workflow connections
*/
export const WorkflowEdge = memo(WorkflowEdgeComponent)

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { v4 as uuidv4 } from 'uuid'
@@ -15,8 +15,7 @@ import {
TriggerUtils,
} from '@/lib/workflows/triggers/triggers'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { coerceValue } from '@/executor/utils/start-block'
import { subscriptionKeys } from '@/hooks/queries/subscription'
@@ -33,9 +32,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useWorkflowExecution')
// Module-level guard to prevent duplicate run-from-block executions across hook instances
let runFromBlockGlobalLock = false
// Debug state validation result
interface DebugValidationResult {
isValid: boolean
@@ -102,8 +98,6 @@ export function useWorkflowExecution() {
setActiveBlocks,
setBlockRunStatus,
setEdgeRunStatus,
setLastExecutionSnapshot,
getLastExecutionSnapshot,
} = useExecutionStore()
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
const executionStream = useExecutionStream()
@@ -882,8 +876,6 @@ export function useWorkflowExecution() {
const activeBlocksSet = new Set<string>()
const streamedContent = new Map<string, string>()
const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>()
// Execute the workflow
try {
@@ -930,14 +922,6 @@ export function useWorkflowExecution() {
// Track successful block execution in run path
setBlockRunStatus(data.blockId, 'success')
// Track block state for run-from-block snapshot
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
executed: true,
executionTime: data.durationMs,
})
// Edges already tracked in onBlockStarted, no need to track again
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
@@ -1072,23 +1056,6 @@ export function useWorkflowExecution() {
},
logs: accumulatedBlockLogs,
}
// Store execution snapshot for run-from-block
if (data.success && activeWorkflowId) {
const snapshot: SerializableExecutionState = {
blockStates: Object.fromEntries(accumulatedBlockStates),
executedBlocks: Array.from(executedBlockIds),
blockLogs: accumulatedBlockLogs,
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: Array.from(executedBlockIds),
}
setLastExecutionSnapshot(activeWorkflowId, snapshot)
logger.info('Stored execution snapshot for run-from-block', {
workflowId: activeWorkflowId,
executedBlocksCount: executedBlockIds.size,
})
}
},
onExecutionError: (data) => {
@@ -1409,228 +1376,6 @@ export function useWorkflowExecution() {
setActiveBlocks,
])
/**
* Handles running workflow from a specific block using cached outputs
*/
const handleRunFromBlock = useCallback(
async (blockId: string, workflowId: string) => {
// Prevent duplicate executions across multiple hook instances (panel.tsx and chat.tsx)
if (runFromBlockGlobalLock) {
logger.debug('Run-from-block already in progress (global lock), ignoring duplicate request', {
workflowId,
blockId,
})
return
}
runFromBlockGlobalLock = true
const snapshot = getLastExecutionSnapshot(workflowId)
if (!snapshot) {
logger.error('No execution snapshot available for run-from-block', { workflowId, blockId })
runFromBlockGlobalLock = false
return
}
if (!snapshot.executedBlocks.includes(blockId)) {
logger.error('Block was not executed in the source run', { workflowId, blockId })
runFromBlockGlobalLock = false
return
}
logger.info('Starting run-from-block execution', {
workflowId,
startBlockId: blockId,
snapshotExecutedBlocks: snapshot.executedBlocks.length,
})
setIsExecuting(true)
const workflowEdges = useWorkflowStore.getState().edges
const executionId = uuidv4()
const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>()
const activeBlocksSet = new Set<string>()
try {
await executionStream.executeFromBlock({
workflowId,
startBlockId: blockId,
sourceSnapshot: snapshot,
callbacks: {
onExecutionStarted: (data) => {
logger.info('Run-from-block execution started:', data)
},
onBlockStarted: (data) => {
activeBlocksSet.add(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
const incomingEdges = workflowEdges.filter((edge) => edge.target === data.blockId)
incomingEdges.forEach((edge) => {
setEdgeRunStatus(edge.id, 'success')
})
},
onBlockCompleted: (data) => {
activeBlocksSet.delete(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
setBlockRunStatus(data.blockId, 'success')
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
executed: true,
executionTime: data.durationMs,
})
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
})
addConsole({
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
workflowId,
blockId: data.blockId,
executionId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
},
onBlockError: (data) => {
activeBlocksSet.delete(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
setBlockRunStatus(data.blockId, 'error')
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
})
addConsole({
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
workflowId,
blockId: data.blockId,
executionId,
blockName: data.blockName,
blockType: data.blockType,
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
},
onExecutionCompleted: (data) => {
if (data.success) {
// Merge new states with snapshot states for updated snapshot
const mergedBlockStates: Record<string, BlockState> = { ...snapshot.blockStates }
for (const [bId, state] of accumulatedBlockStates) {
mergedBlockStates[bId] = state
}
const mergedExecutedBlocks = new Set([
...snapshot.executedBlocks,
...executedBlockIds,
])
const updatedSnapshot: SerializableExecutionState = {
...snapshot,
blockStates: mergedBlockStates,
executedBlocks: Array.from(mergedExecutedBlocks),
blockLogs: [...snapshot.blockLogs, ...accumulatedBlockLogs],
activeExecutionPath: Array.from(mergedExecutedBlocks),
}
setLastExecutionSnapshot(workflowId, updatedSnapshot)
logger.info('Updated execution snapshot after run-from-block', {
workflowId,
newBlocksExecuted: executedBlockIds.size,
})
}
},
onExecutionError: (data) => {
logger.error('Run-from-block execution error:', data.error)
},
onExecutionCancelled: () => {
logger.info('Run-from-block execution cancelled')
},
},
})
} catch (error) {
if ((error as Error).name !== 'AbortError') {
logger.error('Run-from-block execution failed:', error)
}
} finally {
setIsExecuting(false)
setActiveBlocks(new Set())
runFromBlockGlobalLock = false
}
},
[
getLastExecutionSnapshot,
setLastExecutionSnapshot,
setIsExecuting,
setActiveBlocks,
setBlockRunStatus,
setEdgeRunStatus,
addConsole,
executionStream,
]
)
// Listen for run-from-block events from the action bar
useEffect(() => {
const handleRunFromBlockEvent = (event: CustomEvent<{ blockId: string; workflowId: string }>) => {
const { blockId, workflowId } = event.detail
handleRunFromBlock(blockId, workflowId)
}
window.addEventListener('run-from-block', handleRunFromBlockEvent as EventListener)
return () => {
window.removeEventListener('run-from-block', handleRunFromBlockEvent as EventListener)
}
}, [handleRunFromBlock])
return {
isExecuting,
isDebugging,
@@ -1641,6 +1386,5 @@ export function useWorkflowExecution() {
handleResumeDebug,
handleCancelDebug,
handleCancelExecution,
handleRunFromBlock,
}
}

View File

@@ -0,0 +1 @@
export { PreviewContextMenu } from './preview-context-menu'

View File

@@ -0,0 +1,97 @@
'use client'
import type { RefObject } from 'react'
import { createPortal } from 'react-dom'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface PreviewContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onCopy: () => void
onSearch?: () => void
wrapText?: boolean
onToggleWrap?: () => void
/** When true, only shows Copy option (for subblock values) */
copyOnly?: boolean
}
/**
* Context menu for preview editor sidebar.
* Provides copy, search, and display options.
* Uses createPortal to render outside any transformed containers (like modals).
*/
export function PreviewContextMenu({
isOpen,
position,
menuRef,
onClose,
onCopy,
onSearch,
wrapText,
onToggleWrap,
copyOnly = false,
}: PreviewContextMenuProps) {
if (typeof document === 'undefined') return null
return createPortal(
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onCopy()
onClose()
}}
>
Copy
</PopoverItem>
{!copyOnly && onSearch && (
<>
<PopoverDivider />
<PopoverItem
onClick={() => {
onSearch()
onClose()
}}
>
Search
</PopoverItem>
</>
)}
{!copyOnly && onToggleWrap && (
<>
<PopoverDivider />
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>,
document.body
)
}

View File

@@ -0,0 +1 @@
export { PreviewEditor } from './preview-editor'

View File

@@ -6,12 +6,24 @@ import {
ArrowUp,
ChevronDown as ChevronDownIcon,
ChevronUp,
ExternalLink,
Maximize2,
RepeatIcon,
SplitIcon,
X,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
import {
Badge,
Button,
ChevronDown,
Code,
Combobox,
Input,
Label,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
@@ -22,15 +34,42 @@ import {
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { getBlock } from '@/blocks'
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { navigatePath } from '@/executor/variables/resolvers/reference'
import { useWorkflowState } from '@/hooks/queries/workflows'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
/**
* CSS override to show full opacity and prevent interaction in readonly preview mode.
* Extracted to avoid duplicating the style block in multiple places.
*/
const READONLY_PREVIEW_STYLES = `
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`
/**
* Format a value for display as JSON string
@@ -123,44 +162,31 @@ function formatInlineValue(value: unknown): string {
return String(value)
}
interface ExecutionDataSectionProps {
interface CollapsibleSectionProps {
title: string
data: unknown
defaultExpanded?: boolean
children: React.ReactNode
isEmpty?: boolean
emptyMessage?: string
/** Whether this section represents an error state (styles title red) */
isError?: boolean
wrapText?: boolean
searchQuery?: string
currentMatchIndex?: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
onContextMenu?: (e: React.MouseEvent) => void
}
/**
* Collapsible section for execution data (input/output)
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
* Collapsible section wrapper for organizing preview editor content
*/
function ExecutionDataSection({
function CollapsibleSection({
title,
data,
defaultExpanded = false,
children,
isEmpty = false,
emptyMessage = 'No data',
isError = false,
wrapText = true,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
onContextMenu,
}: ExecutionDataSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const jsonString = useMemo(() => {
if (!data) return ''
return formatValueAsJson(data)
}, [data])
const isEmpty = jsonString === '—' || jsonString === ''
}: CollapsibleSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => setIsExpanded(!isExpanded)}
@@ -199,20 +225,10 @@ function ExecutionDataSection({
<>
{isEmpty ? (
<div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
<span className='text-[12px] text-[var(--text-tertiary)]'>No data</span>
<span className='text-[12px] text-[var(--text-tertiary)]'>{emptyMessage}</span>
</div>
) : (
<div onContextMenu={onContextMenu} ref={contentRef}>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={searchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={onMatchCountChange}
/>
</div>
children
)}
</>
)}
@@ -261,9 +277,12 @@ function ConnectionsSection({
const [expandedVariables, setExpandedVariables] = useState(true)
const [expandedEnvVars, setExpandedEnvVars] = useState(true)
/** Stable string of connection IDs to prevent effect from running on every render */
const connectionIds = useMemo(() => connections.map((c) => c.blockId).join(','), [connections])
useEffect(() => {
setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
}, [connections])
setExpandedBlocks(new Set(connectionIds.split(',').filter(Boolean)))
}, [connectionIds])
const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0
@@ -549,27 +568,22 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
const isLoop = block.type === 'loop'
const config = isLoop ? SUBFLOW_CONFIG.loop : SUBFLOW_CONFIG.parallel
// Determine current type
const currentType = isLoop
? loop?.loopType || (block.data?.loopType as string) || 'for'
: parallel?.parallelType || (block.data?.parallelType as string) || 'count'
// Build type options for combobox - matches SubflowEditor
const typeOptions = Object.entries(config.typeLabels).map(([value, label]) => ({
value,
label,
}))
// Determine mode
const isCountMode = currentType === 'for' || currentType === 'count'
const isConditionMode = currentType === 'while' || currentType === 'doWhile'
// Get iterations value
const iterations = isLoop
? (loop?.iterations ?? (block.data?.count as number) ?? 5)
: (parallel?.count ?? (block.data?.count as number) ?? 1)
// Get collection/condition value
const getEditorValue = (): string => {
if (isConditionMode && isLoop) {
if (currentType === 'while') {
@@ -589,7 +603,6 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
const editorValue = getEditorValue()
// Get label for configuration field - matches SubflowEditor exactly
const getConfigLabel = (): string => {
if (isCountMode) {
return `${isLoop ? 'Loop' : 'Parallel'} Iterations`
@@ -601,7 +614,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
}
return (
<div className='flex-1 overflow-y-auto overflow-x-hidden pt-[5px] pb-[8px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden pt-[8px] pb-[8px]'>
{/* Type Selection - matches SubflowEditor */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
@@ -703,6 +716,8 @@ interface PreviewEditorProps {
isExecutionMode?: boolean
/** Optional close handler - if not provided, no close button is shown */
onClose?: () => void
/** Callback to drill down into a nested workflow block */
onDrillDown?: (blockId: string, childWorkflowState: WorkflowState) => void
}
/** Minimum height for the connections section (header only) */
@@ -725,8 +740,8 @@ function PreviewEditorContent({
parallels,
isExecutionMode = false,
onClose,
onDrillDown,
}: PreviewEditorProps) {
// Convert Record<string, Variable> to Array<Variable> for iteration
const normalizedWorkflowVariables = useMemo(() => {
if (!workflowVariables) return []
return Object.values(workflowVariables)
@@ -735,10 +750,39 @@ function PreviewEditorContent({
const blockConfig = getBlock(block.type) as BlockConfig | undefined
const subBlockValues = block.subBlocks || {}
const params = useParams()
const workspaceId = params.workspaceId as string
const isWorkflowBlock = block.type === 'workflow' || block.type === 'workflow_input'
/** Extracts child workflow ID from subblock values for workflow blocks */
const childWorkflowId = useMemo(() => {
if (!isWorkflowBlock) return null
const workflowIdValue = subBlockValues?.workflowId
if (workflowIdValue && typeof workflowIdValue === 'object' && 'value' in workflowIdValue) {
return (workflowIdValue as { value: unknown }).value as string | null
}
return workflowIdValue as string | null
}, [isWorkflowBlock, subBlockValues?.workflowId])
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
childWorkflowId ?? undefined
)
/** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => {
if (!childWorkflowId || !childWorkflowState) return
if (isExecutionMode && onDrillDown) {
onDrillDown(block.id, childWorkflowState)
} else if (workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
}
}, [childWorkflowId, childWorkflowState, isExecutionMode, onDrillDown, block.id, workspaceId])
const contentRef = useRef<HTMLDivElement>(null)
const subBlocksRef = useRef<HTMLDivElement>(null)
// Connections resize state
const [connectionsHeight, setConnectionsHeight] = useState(DEFAULT_CONNECTIONS_HEIGHT)
const [isResizing, setIsResizing] = useState(false)
const startYRef = useRef<number>(0)
@@ -846,10 +890,8 @@ function PreviewEditorContent({
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
const deltaY = startYRef.current - e.clientY // Inverted because we're resizing from bottom up
const deltaY = startYRef.current - e.clientY
let newHeight = startHeightRef.current + deltaY
// Clamp height between fixed min and max for stable behavior
newHeight = Math.max(MIN_CONNECTIONS_HEIGHT, Math.min(MAX_CONNECTIONS_HEIGHT, newHeight))
setConnectionsHeight(newHeight)
}
@@ -871,7 +913,6 @@ function PreviewEditorContent({
}
}, [isResizing])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5
const blockNameToId = useMemo(() => {
@@ -891,7 +932,7 @@ function PreviewEditorContent({
if (!allBlockExecutions || !workflowBlocks) return undefined
if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
const inner = reference.slice(1, -1) // Remove < and >
const inner = reference.slice(1, -1)
const parts = inner.split('.')
if (parts.length < 1) return undefined
@@ -1007,12 +1048,10 @@ function PreviewEditorContent({
[blockConfig?.subBlocks]
)
// Check if this is a subflow block (loop or parallel)
const isSubflow = block.type === 'loop' || block.type === 'parallel'
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
const parallelConfig = block.type === 'parallel' ? parallels?.[block.id] : undefined
// Handle subflow blocks
if (isSubflow) {
const isLoop = block.type === 'loop'
const SubflowIcon = isLoop ? RepeatIcon : SplitIcon
@@ -1043,27 +1082,7 @@ function PreviewEditorContent({
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'>
<div className='readonly-preview px-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
<style>{READONLY_PREVIEW_STYLES}</style>
<SubflowConfigDisplay block={block} loop={loopConfig} parallel={parallelConfig} />
</div>
</div>
@@ -1095,8 +1114,6 @@ function PreviewEditorContent({
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
// Only filter out trigger-mode subblocks for non-trigger blocks
// Trigger-only blocks (category 'triggers') should display their trigger subblocks
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (
@@ -1145,7 +1162,7 @@ function PreviewEditorContent({
{/* Content area */}
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Subblocks Section */}
{/* Main content sections */}
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'>
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
@@ -1159,91 +1176,154 @@ function PreviewEditorContent({
</div>
)}
{/* Execution Input/Output (if provided) */}
{executionData &&
(executionData.input !== undefined || executionData.output !== undefined) ? (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{/* Execution Status & Duration Header */}
{(executionData.status || executionData.durationMs !== undefined) && (
<div className='flex items-center justify-between'>
{executionData.status && (
<Badge variant={statusVariant} size='sm' dot>
<span className='capitalize'>{executionData.status}</span>
</Badge>
)}
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs, { precision: 2 })}
</span>
)}
</div>
{/* Execution Status & Duration Header */}
{executionData && (executionData.status || executionData.durationMs !== undefined) && (
<div className='flex min-w-0 items-center justify-between overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{executionData.status && (
<Badge variant={statusVariant} size='sm' dot>
<span className='capitalize'>{executionData.status}</span>
</Badge>
)}
{/* Divider between Status/Duration and Input/Output */}
{(executionData.status || executionData.durationMs !== undefined) &&
(executionData.input !== undefined || executionData.output !== undefined) && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Input Section */}
{executionData.input !== undefined && (
<ExecutionDataSection
title='Input'
data={executionData.input}
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={contentRef}
onContextMenu={handleExecutionContextMenu}
/>
)}
{/* Divider between Input and Output */}
{executionData.input !== undefined && executionData.output !== undefined && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Output Section */}
{executionData.output !== undefined && (
<ExecutionDataSection
title={executionData.status === 'error' ? 'Error' : 'Output'}
data={executionData.output}
isError={executionData.status === 'error'}
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={contentRef}
onContextMenu={handleExecutionContextMenu}
/>
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs, { precision: 2 })}
</span>
)}
</div>
) : null}
)}
{/* Input Section - Collapsible */}
{executionData?.input !== undefined && (
<CollapsibleSection
title='Input'
defaultExpanded={false}
isEmpty={
formatValueAsJson(executionData.input) === '—' ||
formatValueAsJson(executionData.input) === ''
}
emptyMessage='No input data'
>
<div onContextMenu={handleExecutionContextMenu} ref={contentRef}>
<Code.Viewer
code={formatValueAsJson(executionData.input)}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
</div>
</CollapsibleSection>
)}
{/* Output Section - Collapsible, expanded by default */}
{executionData?.output !== undefined && (
<CollapsibleSection
title={executionData.status === 'error' ? 'Error' : 'Output'}
defaultExpanded={true}
isEmpty={
formatValueAsJson(executionData.output) === '—' ||
formatValueAsJson(executionData.output) === ''
}
emptyMessage='No output data'
isError={executionData.status === 'error'}
>
<div onContextMenu={handleExecutionContextMenu}>
<Code.Viewer
code={formatValueAsJson(executionData.output)}
language='json'
className={cn(
'!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]',
executionData.status === 'error' && 'text-[var(--text-error)]'
)}
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
</div>
</CollapsibleSection>
)}
{/* Workflow Preview - only for workflow blocks with a selected child workflow */}
{isWorkflowBlock && childWorkflowId && (
<div className='px-[8px] pt-[12px]'>
<div className='subblock-content flex flex-col gap-[9.5px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)] leading-none'>
Workflow Preview
</div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{isLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
) : childWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<PreviewWorkflow
workflowState={childWorkflowState}
height={160}
width='100%'
isPannable={true}
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleExpandChildWorkflow}
className='absolute right-[6px] bottom-[6px] z-10 h-[24px] w-[24px] cursor-pointer border border-[var(--border)] bg-[var(--surface-2)] p-0 hover:bg-[var(--surface-4)]'
>
{isExecutionMode && onDrillDown ? (
<Maximize2 className='h-[12px] w-[12px]' />
) : (
<ExternalLink className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isExecutionMode && onDrillDown ? 'Expand workflow' : 'Open in new tab'}
</Tooltip.Content>
</Tooltip.Root>
</>
) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
Unable to load preview
</span>
</div>
)}
</div>
</div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</div>
)}
{/* Subblock Values - Using SubBlock components in preview mode */}
<div className='readonly-preview px-[8px] py-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
<div className='readonly-preview px-[8px] pt-[12px] pb-[8px]'>
<style>{READONLY_PREVIEW_STYLES}</style>
{visibleSubBlocks.length > 0 ? (
<div className='flex flex-col'>
{visibleSubBlocks.map((subBlockConfig, index) => (
@@ -1349,7 +1429,7 @@ function PreviewEditorContent({
)}
{/* Context Menu */}
<SnapshotContextMenu
<PreviewContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useMemo } from 'react'
import { type CSSProperties, memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
@@ -23,6 +23,27 @@ interface SubBlockValueEntry {
value: unknown
}
/**
* Handle style constants for preview blocks.
* Extracted to avoid recreating style objects on each render.
*/
const HANDLE_STYLES = {
horizontal: '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]',
vertical: '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]',
right:
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
error:
'!z-[10] !border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
} as const
/** Reusable style object for error handles positioned at bottom-right */
const ERROR_HANDLE_STYLE: CSSProperties = {
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}
interface WorkflowPreviewBlockData {
type: string
name: string
@@ -35,6 +56,8 @@ interface WorkflowPreviewBlockData {
executionStatus?: ExecutionStatus
/** Subblock values from the workflow state */
subBlockValues?: Record<string, SubBlockValueEntry | unknown>
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
}
/**
@@ -157,21 +180,17 @@ function resolveToolsDisplay(
if (!tool || typeof tool !== 'object') return null
const t = tool as Record<string, unknown>
// Priority 1: Use tool.title if already populated
if (t.title && typeof t.title === 'string') return t.title
// Priority 2: Extract from inline schema (legacy format)
const schema = t.schema as Record<string, unknown> | undefined
if (schema?.function && typeof schema.function === 'object') {
const fn = schema.function as Record<string, unknown>
if (fn.name && typeof fn.name === 'string') return fn.name
}
// Priority 3: Extract from OpenAI function format
const fn = t.function as Record<string, unknown> | undefined
if (fn?.name && typeof fn.name === 'string') return fn.name
// Priority 4: Resolve built-in tool blocks from registry
if (
typeof t.type === 'string' &&
t.type !== 'custom-tool' &&
@@ -204,21 +223,16 @@ function resolveToolsDisplay(
* - Shows '-' for other selector types that need hydration
*/
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
// Mask password fields
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
// Resolve various display names (synchronous access, matching WorkflowBlock priority)
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
const toolsDisplay = resolveToolsDisplay(subBlock, rawValue)
const workflowName = resolveWorkflowName(subBlock, rawValue)
// Check if this is a selector type that needs hydration (show '-' for raw IDs)
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
// Compute final display value matching WorkflowBlock logic
// Priority order matches WorkflowBlock: masked > hydrated names > selector fallback > raw value
const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
@@ -258,6 +272,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
isPreviewSelected = false,
executionStatus,
subBlockValues,
lightweight = false,
} = data
const blockConfig = getBlock(type)
@@ -268,44 +283,68 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
)
const rawValues = useMemo(() => {
if (!subBlockValues) return {}
if (lightweight || !subBlockValues) return {}
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
acc[key] = extractValue(entry)
return acc
}, {})
}, [subBlockValues])
}, [subBlockValues, lightweight])
const visibleSubBlocks = useMemo(() => {
if (!blockConfig?.subBlocks) return []
const isStarterOrTrigger =
blockConfig.category === 'triggers' || type === 'starter' || isTrigger
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
const effectiveTrigger = isTrigger || type === 'starter'
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden) return false
if (subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
// Handle trigger mode visibility
if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? subBlock.mode === 'trigger' || !subBlock.mode
: subBlock.mode === 'trigger'
if (!isValidTriggerSubblock) return false
} else {
if (subBlock.mode === 'trigger') return false
}
/** Skip value-dependent visibility checks in lightweight mode */
if (lightweight) return !subBlock.condition
// Check advanced mode visibility
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
return false
}
// Check condition visibility
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
})
}, [blockConfig?.subBlocks, blockConfig?.category, type, isTrigger, canonicalIndex, rawValues])
}, [
lightweight,
blockConfig?.subBlocks,
blockConfig?.triggers?.enabled,
blockConfig?.category,
type,
isTrigger,
canonicalIndex,
rawValues,
])
/**
* Compute condition rows for condition blocks
* Compute condition rows for condition blocks.
* In lightweight mode, returns default structure without parsing values.
*/
const conditionRows = useMemo(() => {
if (type !== 'condition') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
if (lightweight) return defaultRows
const conditionsValue = rawValues.conditions
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
@@ -325,21 +364,24 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
}
} catch {
// Failed to parse, use fallback
/* empty */
}
return [
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
}, [type, rawValues])
return defaultRows
}, [type, rawValues, lightweight])
/**
* Compute router rows for router_v2 blocks
* Compute router rows for router_v2 blocks.
* In lightweight mode, returns default structure without parsing values.
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [{ id: 'route1', value: '' }]
if (lightweight) return defaultRows
const routesValue = rawValues.routes
const raw = typeof routesValue === 'string' ? routesValue : undefined
@@ -357,11 +399,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
}
} catch {
// Failed to parse, use fallback
/* empty */
}
return [{ id: 'route1', value: '' }]
}, [type, rawValues])
return defaultRows
}, [type, rawValues, lightweight])
if (!blockConfig) {
return null
@@ -379,9 +421,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
? routerRows.length > 0 || shouldShowDefaultHandles
: hasSubBlocks || shouldShowDefaultHandles
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
@@ -406,7 +445,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
id='target'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
@@ -439,36 +478,37 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
{hasContentBelowHeader && (
<div className='flex flex-col gap-[8px] p-[8px]'>
{type === 'condition' ? (
// Condition block: render condition rows
conditionRows.map((cond) => (
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
<SubBlockRow
key={cond.id}
title={cond.title}
value={lightweight ? undefined : getDisplayValue(cond.value)}
/>
))
) : type === 'router_v2' ? (
// Router block: render context + route rows
<>
<SubBlockRow
key='context'
title='Context'
value={getDisplayValue(rawValues.context)}
value={lightweight ? undefined : getDisplayValue(rawValues.context)}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={route.id}
title={`Route ${index + 1}`}
value={getDisplayValue(route.value)}
value={lightweight ? undefined : getDisplayValue(route.value)}
/>
))}
</>
) : (
// Standard blocks: render visible subblocks
visibleSubBlocks.map((subBlock) => {
const rawValue = rawValues[subBlock.id]
const rawValue = lightweight ? undefined : rawValues[subBlock.id]
return (
<SubBlockRow
key={subBlock.id}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={lightweight ? undefined : subBlock}
rawValue={rawValue}
/>
)
@@ -479,27 +519,101 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
</div>
)}
{/* Source handle */}
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
style={
horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
{/* Condition block handles */}
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y + condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${cond.id}`}
type='source'
position={Position.Right}
id={`condition-${cond.id}`}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Router block handles */}
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Source and error handles for non-condition/router blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={horizontalHandles ? HANDLE_STYLES.right : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
{shouldShowDefaultHandles && (
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
)}
</>
)}
</div>
)
}
/**
* Custom comparison function for React.memo optimization.
* Uses fast-path primitive comparison before shallow comparing subBlockValues.
* @param prevProps - Previous render props
* @param nextProps - Next render props
* @returns True if render should be skipped (props are equal)
*/
function shouldSkipPreviewBlockRender(
prevProps: NodeProps<WorkflowPreviewBlockData>,
nextProps: NodeProps<WorkflowPreviewBlockData>
): boolean {
// Check primitive props first (fast path)
if (
prevProps.id !== nextProps.id ||
prevProps.data.type !== nextProps.data.type ||
@@ -508,38 +622,41 @@ function shouldSkipPreviewBlockRender(
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
prevProps.data.enabled !== nextProps.data.enabled ||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
prevProps.data.executionStatus !== nextProps.data.executionStatus
prevProps.data.executionStatus !== nextProps.data.executionStatus ||
prevProps.data.lightweight !== nextProps.data.lightweight
) {
return false
}
// Compare subBlockValues by reference first
/** Skip subBlockValues comparison in lightweight mode */
if (nextProps.data.lightweight) return true
const prevValues = prevProps.data.subBlockValues
const nextValues = nextProps.data.subBlockValues
if (prevValues === nextValues) {
return true
}
if (prevValues === nextValues) return true
if (!prevValues || !nextValues) return false
if (!prevValues || !nextValues) {
return false
}
// Shallow compare keys and values
const prevKeys = Object.keys(prevValues)
const nextKeys = Object.keys(nextValues)
if (prevKeys.length !== nextKeys.length) {
return false
}
if (prevKeys.length !== nextKeys.length) return false
for (const key of prevKeys) {
if (prevValues[key] !== nextValues[key]) {
return false
}
if (prevValues[key] !== nextValues[key]) return false
}
return true
}
export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)
/**
* Preview block component for workflow visualization in readonly contexts.
* Optimized for rendering without hooks or store subscriptions.
*
* @remarks
* - Renders block header, subblock values, and connection handles
* - Supports condition, router, and standard block types
* - Shows error handles for non-trigger blocks
* - Displays execution status via colored ring overlays
*/
export const PreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)

View File

@@ -0,0 +1 @@
export { PreviewBlock } from './block'

View File

@@ -0,0 +1 @@
export { PreviewSubflow } from './subflow'

View File

@@ -5,6 +5,9 @@ import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
/** Execution status for subflows in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewSubflowData {
name: string
width?: number
@@ -12,6 +15,10 @@ interface WorkflowPreviewSubflowData {
kind: 'loop' | 'parallel'
/** Whether this subflow is selected in preview mode */
isPreviewSelected?: boolean
/** Execution status for highlighting the subflow container */
executionStatus?: ExecutionStatus
/** Skips expensive computations for thumbnails/template previews (unused in subflow, for consistency) */
lightweight?: boolean
}
/**
@@ -20,7 +27,7 @@ interface WorkflowPreviewSubflowData {
* or interactive features.
*/
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
const { name, width = 500, height = 300, kind, isPreviewSelected = false, executionStatus } = data
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
@@ -35,6 +42,9 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
const rightHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
return (
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
@@ -43,10 +53,18 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
height,
}}
>
{/* Selection ring overlay */}
{/* Selection ring overlay (takes priority over execution rings) */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Success ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasSuccess && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-tertiary-2)]' />
)}
{/* Error ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasError && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--text-error)]' />
)}
{/* Target handle on left (input to the subflow) */}
<Handle
@@ -110,4 +128,4 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
)
}
export const WorkflowPreviewSubflow = memo(WorkflowPreviewSubflowInner)
export const PreviewSubflow = memo(WorkflowPreviewSubflowInner)

View File

@@ -0,0 +1 @@
export { getLeftmostBlockId, PreviewWorkflow } from './preview-workflow'

View File

@@ -0,0 +1,613 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import ReactFlow, {
ConnectionLineType,
type Edge,
type EdgeTypes,
type Node,
type NodeTypes,
ReactFlowProvider,
useReactFlow,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger'
import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('PreviewWorkflow')
/**
* Gets block dimensions for preview purposes.
* For containers, uses stored dimensions or defaults.
* For regular blocks, uses stored height or estimates based on type.
*/
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
if (block.type === 'loop' || block.type === 'parallel') {
return {
width: block.data?.width
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
return estimateBlockDimensions(block.type)
}
/**
* Calculates container dimensions based on child block positions and sizes.
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
*/
function calculateContainerDimensions(
containerId: string,
blocks: Record<string, BlockState>
): { width: number; height: number } {
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
if (childBlocks.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childBlocks) {
if (!child?.position) continue
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
}
/**
* Finds the leftmost block ID from a workflow state.
* Excludes subflow containers (loop/parallel) from consideration.
* @param workflowState - The workflow state to search
* @returns The ID of the leftmost block, or null if no blocks exist
*/
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
if (!workflowState?.blocks) return null
let leftmostId: string | null = null
let minX = Number.POSITIVE_INFINITY
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
if (!block || block.type === 'loop' || block.type === 'parallel') continue
const x = block.position?.x ?? Number.POSITIVE_INFINITY
if (x < minX) {
minX = x
leftmostId = blockId
}
}
return leftmostId
}
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
/** Calculates absolute position for blocks, handling nested subflows */
function calculateAbsolutePosition(
block: BlockState,
blocks: Record<string, BlockState>
): { x: number; y: number } {
if (!block.data?.parentId) {
return block.position
}
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block`)
return block.position
}
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
interface PreviewWorkflowProps {
workflowState: WorkflowState
className?: string
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
fitPadding?: number
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when a node is right-clicked */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when the canvas (empty area) is clicked */
onPaneClick?: () => void
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string }>
/** Currently selected block ID for highlighting */
selectedBlockId?: string | null
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
}
/**
* Preview node types using minimal components without hooks or store subscriptions.
* This prevents interaction issues while allowing canvas panning and node clicking.
*/
const previewNodeTypes: NodeTypes = {
workflowBlock: PreviewBlock,
noteBlock: PreviewBlock,
subflowNode: PreviewSubflow,
}
const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge,
}
interface FitViewOnChangeProps {
nodeIds: string
fitPadding: number
containerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
* Only triggers on actual node additions/removals, not on selection changes.
* Must be rendered inside ReactFlowProvider.
*/
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null)
useEffect(() => {
if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds
if (!shouldFit) return
lastNodeIdsRef.current = nodeIds
const timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 200 })
}, 50)
return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView])
useEffect(() => {
const container = containerRef.current
if (!container) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const resizeObserver = new ResizeObserver(() => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 150 })
}, 100)
})
resizeObserver.observe(container)
return () => {
if (timeoutId) clearTimeout(timeoutId)
resizeObserver.disconnect()
}
}, [containerRef, fitPadding, fitView])
return null
}
/**
* Readonly workflow component for visualizing workflow state.
* Renders blocks, subflows, and edges with execution status highlighting.
*
* @remarks
* - Supports panning and node click interactions
* - Shows execution path via green edges for successful paths
* - Error edges display red by default, green when error path was taken
* - Fits view automatically when nodes change or container resizes
*/
export function PreviewWorkflow({
workflowState,
className,
height = '100%',
width = '100%',
isPannable = true,
defaultPosition,
defaultZoom = 0.8,
fitPadding = 0.25,
onNodeClick,
onNodeContextMenu,
onPaneClick,
cursorStyle = 'grab',
executedBlocks,
selectedBlockId,
lightweight = false,
}: PreviewWorkflowProps) {
const containerRef = useRef<HTMLDivElement>(null)
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.blocks || {}).length,
ids: Object.keys(workflowState.blocks || {}).join(','),
}
}, [workflowState.blocks, isValidWorkflowState])
const loopsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
}
}, [workflowState.loops, isValidWorkflowState])
const parallelsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
}
}, [workflowState.parallels, isValidWorkflowState])
/** Map of subflow ID to child block IDs */
const subflowChildrenMap = useMemo(() => {
if (!isValidWorkflowState) return new Map<string, string[]>()
const map = new Map<string, string[]>()
for (const [blockId, block] of Object.entries(workflowState.blocks || {})) {
const parentId = block?.data?.parentId
if (parentId) {
const children = map.get(parentId) || []
children.push(blockId)
map.set(parentId, children)
}
}
return map
}, [workflowState.blocks, isValidWorkflowState])
/** Derives subflow execution status from child blocks */
const getSubflowExecutionStatus = useMemo(() => {
return (subflowId: string): ExecutionStatus | undefined => {
if (!executedBlocks) return undefined
const childIds = subflowChildrenMap.get(subflowId)
if (!childIds?.length) return undefined
const childStatuses = childIds.map((id) => executedBlocks[id]).filter(Boolean)
if (childStatuses.length === 0) return undefined
if (childStatuses.some((s) => s.status === 'error')) return 'error'
if (childStatuses.some((s) => s.status === 'success')) return 'success'
return 'not-executed'
}
}, [executedBlocks, subflowChildrenMap])
/** Gets execution status for any block, deriving subflow status from children */
const getBlockExecutionStatus = useMemo(() => {
return (blockId: string): { status: string; executed: boolean } | undefined => {
if (!executedBlocks) return undefined
const directStatus = executedBlocks[blockId]
if (directStatus) {
return { status: directStatus.status, executed: true }
}
const block = workflowState.blocks?.[blockId]
if (block && (block.type === 'loop' || block.type === 'parallel')) {
const subflowStatus = getSubflowExecutionStatus(blockId)
if (subflowStatus) {
return { status: subflowStatus, executed: true }
}
const incomingEdge = workflowState.edges?.find((e) => e.target === blockId)
if (incomingEdge && executedBlocks[incomingEdge.source]?.status === 'success') {
return { status: 'not-executed', executed: true }
}
}
return undefined
}
}, [executedBlocks, workflowState.blocks, workflowState.edges, getSubflowExecutionStatus])
const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: workflowState.edges?.length || 0,
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
}
}, [workflowState.edges, isValidWorkflowState])
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
const nodeArray: Node[] = []
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
if (!block || !block.type) {
logger.warn(`Skipping invalid block: ${blockId}`)
return
}
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
const subflowExecutionStatus = getSubflowExecutionStatus(blockId)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
draggable: false,
data: {
name: block.name,
width: dimensions.width,
height: dimensions.height,
kind: block.type as 'loop' | 'parallel',
isPreviewSelected: isSelected,
executionStatus: subflowExecutionStatus,
lightweight,
},
})
return
}
const isSelected = selectedBlockId === blockId
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
executionStatus = 'error'
} else if (blockExecution.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
zIndex: block.data?.parentId ? 10 : undefined,
data: {
type: block.type,
name: block.name,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus,
subBlockValues: block.subBlocks,
lightweight,
},
})
})
return nodeArray
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
workflowState.blocks,
isValidWorkflowState,
executedBlocks,
selectedBlockId,
getSubflowExecutionStatus,
lightweight,
])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
/**
* Determines edge execution status for visualization.
* Error edges turn green when taken (source errored, target executed).
* Normal edges turn green when both source succeeded and target executed.
*/
const getEdgeExecutionStatus = (edge: {
source: string
target: string
sourceHandle?: string | null
}): ExecutionStatus | undefined => {
if (!executedBlocks) return undefined
const sourceStatus = getBlockExecutionStatus(edge.source)
const targetStatus = getBlockExecutionStatus(edge.target)
const isErrorEdge = edge.sourceHandle === 'error'
if (isErrorEdge) {
return sourceStatus?.status === 'error' && targetStatus?.executed
? 'success'
: 'not-executed'
}
const isSubflowStartEdge =
edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source'
if (isSubflowStartEdge) {
const incomingEdge = workflowState.edges?.find((e) => e.target === edge.source)
const incomingSucceeded = incomingEdge
? executedBlocks[incomingEdge.source]?.status === 'success'
: false
return incomingSucceeded ? 'success' : 'not-executed'
}
const targetBlock = workflowState.blocks?.[edge.target]
const targetIsSubflow =
targetBlock && (targetBlock.type === 'loop' || targetBlock.type === 'parallel')
if (sourceStatus?.status === 'success' && (targetStatus?.executed || targetIsSubflow)) {
return 'success'
}
return 'not-executed'
}
return (workflowState.edges || []).map((edge) => {
const status = getEdgeExecutionStatus(edge)
const isErrorEdge = edge.sourceHandle === 'error'
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
data: {
...(status ? { executionStatus: status } : {}),
sourceHandle: edge.sourceHandle,
},
zIndex: status === 'success' ? 10 : isErrorEdge ? 5 : 0,
}
})
}, [
edgesStructure,
workflowState.edges,
workflowState.blocks,
isValidWorkflowState,
executedBlocks,
getBlockExecutionStatus,
])
if (!isValidWorkflowState) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
return (
<ReactFlowProvider>
<div
ref={containerRef}
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
>
<style>{`
/* Canvas cursor - grab on the flow container and pane */
.preview-mode .react-flow { cursor: ${cursorStyle}; }
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
/* Active/grabbing cursor when dragging */
${
cursorStyle === 'grab'
? `
.preview-mode .react-flow:active { cursor: grabbing; }
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
`
: ''
}
/* Node cursor - pointer on nodes when onNodeClick is provided */
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
`}</style>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
fitViewOptions={{ padding: fitPadding }}
panOnScroll={isPannable}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
onNodeClick={
onNodeClick
? (event, node) => {
logger.debug('Node clicked:', { nodeId: node.id, event })
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onNodeContextMenu={
onNodeContextMenu
? (event, node) => {
event.preventDefault()
event.stopPropagation()
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onPaneClick={onPaneClick}
/>
<FitViewOnChange
nodeIds={blocksStructure.ids}
fitPadding={fitPadding}
containerRef={containerRef}
/>
</div>
</ReactFlowProvider>
)
}

View File

@@ -1,2 +1,6 @@
export { PreviewContextMenu } from './components/preview-context-menu'
export { PreviewEditor } from './components/preview-editor'
export { getLeftmostBlockId, WorkflowPreview } from './preview'
export { getLeftmostBlockId, PreviewWorkflow } from './components/preview-workflow'
export { PreviewBlock } from './components/preview-workflow/components/block'
export { PreviewSubflow } from './components/preview-workflow/components/subflow'
export { buildBlockExecutions, Preview } from './preview'

View File

@@ -1,499 +1,292 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import ReactFlow, {
ConnectionLineType,
type Edge,
type EdgeTypes,
type Node,
type NodeTypes,
ReactFlowProvider,
useReactFlow,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowLeft } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { PreviewEditor } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-editor'
import {
getLeftmostBlockId,
PreviewWorkflow,
} from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview')
/**
* Gets block dimensions for preview purposes.
* For containers, uses stored dimensions or defaults.
* For regular blocks, uses stored height or estimates based on type.
*/
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
if (block.type === 'loop' || block.type === 'parallel') {
return {
width: block.data?.width
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
return estimateBlockDimensions(block.type)
interface TraceSpan {
blockId?: string
input?: unknown
output?: unknown
status?: string
duration?: number
children?: TraceSpan[]
}
/**
* Calculates container dimensions based on child block positions and sizes.
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
*/
function calculateContainerDimensions(
containerId: string,
blocks: Record<string, BlockState>
): { width: number; height: number } {
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
if (childBlocks.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childBlocks) {
if (!child?.position) continue
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
/** Child trace spans for nested workflow blocks */
children?: TraceSpan[]
}
/**
* Finds the leftmost block ID from a workflow state.
* Returns the block with the smallest x position, excluding subflow containers (loop/parallel).
*/
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
if (!workflowState?.blocks) return null
let leftmostId: string | null = null
let minX = Number.POSITIVE_INFINITY
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
if (!block || block.type === 'loop' || block.type === 'parallel') continue
const x = block.position?.x ?? Number.POSITIVE_INFINITY
if (x < minX) {
minX = x
leftmostId = blockId
}
}
return leftmostId
}
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewProps {
/** Represents a level in the workflow navigation stack */
interface WorkflowStackEntry {
workflowState: WorkflowState
className?: string
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
fitPadding?: number
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when a node is right-clicked */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when the canvas (empty area) is clicked */
onPaneClick?: () => void
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string }>
/** Currently selected block ID for highlighting */
selectedBlockId?: string | null
traceSpans: TraceSpan[]
blockExecutions: Record<string, BlockExecutionData>
}
/**
* Preview node types using minimal components without hooks or store subscriptions.
* This prevents interaction issues while allowing canvas panning and node clicking.
* Extracts child trace spans from a workflow block's execution data.
* Checks both the `children` property (where trace span processing moves them)
* and the legacy `output.childTraceSpans` for compatibility.
*/
const previewNodeTypes: NodeTypes = {
workflowBlock: WorkflowPreviewBlock,
noteBlock: WorkflowPreviewBlock,
subflowNode: WorkflowPreviewSubflow,
}
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
if (!blockExecution) return []
// Define edge types
const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge, // Keep for backward compatibility
}
if (Array.isArray(blockExecution.children) && blockExecution.children.length > 0) {
return blockExecution.children
}
interface FitViewOnChangeProps {
nodeIds: string
fitPadding: number
containerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
* Only triggers on actual node additions/removals, not on selection changes.
* Must be rendered inside ReactFlowProvider.
*/
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null)
// Fit view when nodes change
useEffect(() => {
if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds
if (!shouldFit) return
lastNodeIdsRef.current = nodeIds
const timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 200 })
}, 50)
return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView])
// Fit view when container resizes (debounced to avoid excessive calls during drag)
useEffect(() => {
const container = containerRef.current
if (!container) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const resizeObserver = new ResizeObserver(() => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 150 })
}, 100)
})
resizeObserver.observe(container)
return () => {
if (timeoutId) clearTimeout(timeoutId)
resizeObserver.disconnect()
if (blockExecution.output && typeof blockExecution.output === 'object') {
const output = blockExecution.output as Record<string, unknown>
if (Array.isArray(output.childTraceSpans)) {
return output.childTraceSpans as TraceSpan[]
}
}, [containerRef, fitPadding, fitView])
}
return null
return []
}
export function WorkflowPreview({
workflowState,
/**
* Builds block execution data from trace spans
*/
export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockExecutionData> {
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (traceSpans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of traceSpans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(spans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
children: span.children,
}
}
}
return blockExecutionMap
}
interface PreviewProps {
/** The workflow state to display */
workflowState: WorkflowState
/** Trace spans for the execution (optional - enables execution mode features) */
traceSpans?: TraceSpan[]
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
blockExecutions?: Record<string, BlockExecutionData>
/** Additional CSS class names */
className?: string
/** Height of the component */
height?: string | number
/** Width of the component */
width?: string | number
/** Callback when canvas context menu is opened */
onCanvasContextMenu?: (e: React.MouseEvent) => void
/** Callback when a node context menu is opened */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Whether to show border around the component */
showBorder?: boolean
/** Initial block to select (defaults to leftmost block) */
initialSelectedBlockId?: string | null
/** Whether to auto-select the leftmost block on mount */
autoSelectLeftmost?: boolean
}
/**
* Main preview component that combines PreviewCanvas with PreviewEditor
* and handles nested workflow navigation via a stack.
*
* @remarks
* - Manages navigation stack for drilling into nested workflow blocks
* - Displays back button when viewing nested workflows
* - Properly passes execution data through to nested levels
* - Can be used anywhere a workflow preview with editor is needed
*/
export function Preview({
workflowState: rootWorkflowState,
traceSpans: rootTraceSpans,
blockExecutions: providedBlockExecutions,
className,
height = '100%',
width = '100%',
isPannable = true,
defaultPosition,
defaultZoom = 0.8,
fitPadding = 0.25,
onNodeClick,
onCanvasContextMenu,
onNodeContextMenu,
onPaneClick,
cursorStyle = 'grab',
executedBlocks,
selectedBlockId,
}: WorkflowPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.blocks || {}).length,
ids: Object.keys(workflowState.blocks || {}).join(','),
showBorder = false,
initialSelectedBlockId,
autoSelectLeftmost = true,
}: PreviewProps) {
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
if (initialSelectedBlockId) return initialSelectedBlockId
if (autoSelectLeftmost) {
return getLeftmostBlockId(rootWorkflowState)
}
}, [workflowState.blocks, isValidWorkflowState])
return null
})
const loopsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
/** Stack for nested workflow navigation. Empty means we're at the root level. */
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
/** Block executions for the root level */
const rootBlockExecutions = useMemo(() => {
if (providedBlockExecutions) return providedBlockExecutions
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
return buildBlockExecutions(rootTraceSpans)
}, [providedBlockExecutions, rootTraceSpans])
/** Current block executions - either from stack or root */
const blockExecutions = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].blockExecutions
}
}, [workflowState.loops, isValidWorkflowState])
return rootBlockExecutions
}, [workflowStack, rootBlockExecutions])
const parallelsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
/** Current workflow state - either from stack or root */
const workflowState = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].workflowState
}
}, [workflowState.parallels, isValidWorkflowState])
return rootWorkflowState
}, [workflowStack, rootWorkflowState])
const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: workflowState.edges?.length || 0,
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
}
}, [workflowState.edges, isValidWorkflowState])
/** Whether we're in execution mode (have trace spans/block executions) */
const isExecutionMode = useMemo(() => {
return Object.keys(blockExecutions).length > 0
}, [blockExecutions])
const calculateAbsolutePosition = (
block: any,
blocks: Record<string, any>
): { x: number; y: number } => {
if (!block.data?.parentId) {
return block.position
}
/** Handler to drill down into a nested workflow block */
const handleDrillDown = useCallback(
(blockId: string, childWorkflowState: WorkflowState) => {
const blockExecution = blockExecutions[blockId]
const childTraceSpans = extractChildTraceSpans(blockExecution)
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block: ${block.id}`)
return block.position
}
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
const nodeArray: Node[] = []
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
if (!block || !block.type) {
logger.warn(`Skipping invalid block: ${blockId}`)
return
}
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
// Handle loop/parallel containers
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
draggable: false,
data: {
name: block.name,
width: dimensions.width,
height: dimensions.height,
kind: block.type as 'loop' | 'parallel',
isPreviewSelected: isSelected,
},
})
return
}
// Handle regular blocks
const isSelected = selectedBlockId === blockId
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
executionStatus = 'error'
} else if (blockExecution.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
// Blocks inside subflows need higher z-index to appear above the container
zIndex: block.data?.parentId ? 10 : undefined,
data: {
type: block.type,
name: block.name,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus,
subBlockValues: block.subBlocks,
setWorkflowStack((prev) => [
...prev,
{
workflowState: childWorkflowState,
traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions,
},
})
})
])
return nodeArray
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
workflowState.blocks,
isValidWorkflowState,
executedBlocks,
selectedBlockId,
])
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId)
},
[blockExecutions]
)
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
/** Handler to go back up the stack */
const handleGoBack = useCallback(() => {
setWorkflowStack((prev) => prev.slice(0, -1))
setPinnedBlockId(null)
}, [])
return (workflowState.edges || []).map((edge) => {
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const sourceExecuted = executedBlocks[edge.source]
const targetExecuted = executedBlocks[edge.target]
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
const handleNodeClick = useCallback((blockId: string) => {
setPinnedBlockId(blockId)
}, [])
if (sourceExecuted && targetExecuted) {
// Edge is success if source succeeded and target was executed (even if target errored)
if (sourceExecuted.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
const handlePaneClick = useCallback(() => {
setPinnedBlockId(null)
}, [])
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
data: executionStatus ? { executionStatus } : undefined,
// Raise executed edges above default edges
zIndex: executionStatus === 'success' ? 10 : 0,
}
})
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
const handleEditorClose = useCallback(() => {
setPinnedBlockId(null)
}, [])
if (!isValidWorkflowState) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
useEffect(() => {
setWorkflowStack([])
}, [rootWorkflowState])
const isNested = workflowStack.length > 0
return (
<ReactFlowProvider>
<div
ref={containerRef}
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
>
<style>{`
/* Canvas cursor - grab on the flow container and pane */
.preview-mode .react-flow { cursor: ${cursorStyle}; }
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
<div
style={{ height, width }}
className={cn(
'relative flex overflow-hidden',
showBorder && 'rounded-[4px] border border-[var(--border)]',
className
)}
>
{isNested && (
<div className='absolute top-[12px] left-[12px] z-20'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleGoBack}
className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]'
>
<ArrowLeft className='h-[13px] w-[13px]' />
<span className='font-medium text-[13px]'>Back</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
</Tooltip.Root>
</div>
)}
/* Active/grabbing cursor when dragging */
${
cursorStyle === 'grab'
? `
.preview-mode .react-flow:active { cursor: grabbing; }
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
`
: ''
}
/* Node cursor - pointer on nodes when onNodeClick is provided */
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
`}</style>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
fitViewOptions={{ padding: fitPadding }}
panOnScroll={isPannable}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
onNodeClick={
onNodeClick
? (event, node) => {
logger.debug('Node clicked:', { nodeId: node.id, event })
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onNodeContextMenu={
onNodeContextMenu
? (event, node) => {
event.preventDefault()
event.stopPropagation()
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onPaneClick={onPaneClick}
/>
<FitViewOnChange
nodeIds={blocksStructure.ids}
fitPadding={fitPadding}
containerRef={containerRef}
<div className='h-full flex-1' onContextMenu={onCanvasContextMenu}>
<PreviewWorkflow
workflowState={workflowState}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={handleNodeClick}
onNodeContextMenu={onNodeContextMenu}
onPaneClick={handlePaneClick}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
/>
</div>
</ReactFlowProvider>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode={isExecutionMode}
onClose={handleEditorClose}
onDrillDown={handleDrillDown}
/>
)}
</div>
)
}

View File

@@ -185,6 +185,16 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
return `claude mcp add "${safeName}" --url "${mcpServerUrl}" --header "X-API-Key:$SIM_API_KEY"`
}
// Cursor supports direct URL configuration (no mcp-remote needed)
if (client === 'cursor') {
const cursorConfig = isPublic
? { url: mcpServerUrl }
: { url: mcpServerUrl, headers: { 'X-API-Key': '$SIM_API_KEY' } }
return JSON.stringify({ mcpServers: { [safeName]: cursorConfig } }, null, 2)
}
// Claude Desktop and VS Code still use mcp-remote (stdio transport)
const mcpRemoteArgs = isPublic
? ['-y', 'mcp-remote', mcpServerUrl]
: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY']
@@ -265,14 +275,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
.replace(/[^a-z0-9-]/g, '')
const config = isPublic
? {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl],
}
: {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'],
}
? { url: mcpServerUrl }
: { url: mcpServerUrl, headers: { 'X-API-Key': '$SIM_API_KEY' } }
const base64Config = btoa(JSON.stringify(config))
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(safeName)}&config=${encodeURIComponent(base64Config)}`

View File

@@ -158,7 +158,7 @@ const allNavigationItems: NavigationItem[] = [
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{
id: 'byok',
label: 'BYOK',

View File

@@ -513,6 +513,12 @@ Return ONLY the JSON array.`,
})(),
}),
},
{
id: 'maxTokens',
title: 'Max Output Tokens',
type: 'short-input',
placeholder: 'Enter max tokens (e.g., 4096)...',
},
{
id: 'responseFormat',
title: 'Response Format',
@@ -754,6 +760,7 @@ Example 3 (Array Input):
},
},
temperature: { type: 'number', description: 'Response randomness level' },
maxTokens: { type: 'number', description: 'Maximum number of tokens in the response' },
reasoningEffort: { type: 'string', description: 'Reasoning effort level for GPT-5 models' },
verbosity: { type: 'string', description: 'Verbosity level for GPT-5 models' },
thinkingLevel: { type: 'string', description: 'Thinking level for Gemini 3 models' },

View File

@@ -10,6 +10,7 @@ import {
useRef,
useState,
} from 'react'
import { ChevronRight } from 'lucide-react'
import { highlight, languages } from 'prismjs'
import { List, type RowComponentProps, useDynamicRowHeight, useListRef } from 'react-window'
import 'prismjs/components/prism-javascript'
@@ -36,6 +37,11 @@ export const CODE_LINE_HEIGHT_PX = 21
*/
const GUTTER_WIDTHS = [20, 20, 30, 38, 46, 54] as const
/**
* Width of the collapse column in pixels.
*/
const COLLAPSE_COLUMN_WIDTH = 12
/**
* Calculates the dynamic gutter width based on the number of lines.
* @param lineCount - The total number of lines in the code
@@ -46,6 +52,261 @@ export function calculateGutterWidth(lineCount: number): number {
return GUTTER_WIDTHS[Math.min(digits - 1, GUTTER_WIDTHS.length - 1)]
}
/**
* Information about a collapsible region in code.
*/
interface CollapsibleRegion {
/** Line index where the region starts (0-based) */
startLine: number
/** Line index where the region ends (0-based, inclusive) */
endLine: number
/** Type of collapsible region */
type: 'block' | 'string'
}
/**
* Minimum string length to be considered collapsible.
*/
const MIN_COLLAPSIBLE_STRING_LENGTH = 80
/**
* Regex to match a JSON string value (key: "value" pattern).
* Pre-compiled for performance.
*/
const STRING_VALUE_REGEX = /:\s*"([^"\\]|\\.)*"[,]?\s*$/
/**
* Finds collapsible regions in JSON code by matching braces and detecting long strings.
* A region is collapsible if it spans multiple lines OR contains a long string value.
*
* @param lines - Array of code lines
* @returns Map of start line index to CollapsibleRegion
*/
function findCollapsibleRegions(lines: string[]): Map<number, CollapsibleRegion> {
const regions = new Map<number, CollapsibleRegion>()
const stack: { char: '{' | '['; line: number }[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const stringMatch = line.match(STRING_VALUE_REGEX)
if (stringMatch) {
const colonIdx = line.indexOf('":')
if (colonIdx !== -1) {
const valueStart = line.indexOf('"', colonIdx + 1)
const valueEnd = line.lastIndexOf('"')
if (valueStart !== -1 && valueEnd > valueStart) {
const stringValue = line.slice(valueStart + 1, valueEnd)
// Check if string is long enough or contains escaped newlines
if (stringValue.length >= MIN_COLLAPSIBLE_STRING_LENGTH || stringValue.includes('\\n')) {
regions.set(i, { startLine: i, endLine: i, type: 'string' })
}
}
}
}
// Check for block regions (objects/arrays)
for (const char of line) {
if (char === '{' || char === '[') {
stack.push({ char, line: i })
} else if (char === '}' || char === ']') {
const expected = char === '}' ? '{' : '['
if (stack.length > 0 && stack[stack.length - 1].char === expected) {
const start = stack.pop()!
// Only create a region if it spans multiple lines
if (i > start.line) {
regions.set(start.line, {
startLine: start.line,
endLine: i,
type: 'block',
})
}
}
}
}
}
return regions
}
/**
* Computes visible line indices based on collapsed regions.
* Only block regions hide lines; string regions just truncate content.
*
* @param totalLines - Total number of lines
* @param collapsedLines - Set of line indices that are collapsed (start lines of regions)
* @param regions - Map of collapsible regions
* @returns Sorted array of visible line indices
*/
function computeVisibleLineIndices(
totalLines: number,
collapsedLines: Set<number>,
regions: Map<number, CollapsibleRegion>
): number[] {
if (collapsedLines.size === 0) {
return Array.from({ length: totalLines }, (_, i) => i)
}
// Build sorted list of hidden ranges (only for block regions, not string regions)
const hiddenRanges: Array<{ start: number; end: number }> = []
for (const startLine of collapsedLines) {
const region = regions.get(startLine)
if (region && region.type === 'block' && region.endLine > region.startLine + 1) {
hiddenRanges.push({ start: region.startLine + 1, end: region.endLine - 1 })
}
}
hiddenRanges.sort((a, b) => a.start - b.start)
// Merge overlapping ranges
const merged: Array<{ start: number; end: number }> = []
for (const range of hiddenRanges) {
if (merged.length === 0 || merged[merged.length - 1].end < range.start - 1) {
merged.push(range)
} else {
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, range.end)
}
}
// Build visible indices by skipping hidden ranges
const visible: number[] = []
let rangeIdx = 0
for (let i = 0; i < totalLines; i++) {
while (rangeIdx < merged.length && merged[rangeIdx].end < i) {
rangeIdx++
}
if (rangeIdx < merged.length && i >= merged[rangeIdx].start && i <= merged[rangeIdx].end) {
continue
}
visible.push(i)
}
return visible
}
/**
* Truncates a long string value in a JSON line for collapsed display.
*
* @param line - The original line content
* @returns Truncated line with ellipsis
*/
function truncateStringLine(line: string): string {
const colonIdx = line.indexOf('":')
if (colonIdx === -1) return line
const valueStart = line.indexOf('"', colonIdx + 1)
if (valueStart === -1) return line
const prefix = line.slice(0, valueStart + 1)
const suffix = line.charCodeAt(line.length - 1) === 44 /* ',' */ ? '",' : '"'
const truncated = line.slice(valueStart + 1, valueStart + 31)
return `${prefix}${truncated}...${suffix}`
}
/**
* Custom hook for managing JSON collapse state and computations.
*
* @param lines - Array of code lines
* @param showCollapseColumn - Whether collapse functionality is enabled
* @param language - Programming language for syntax detection
* @returns Object containing collapse state and handlers
*/
function useJsonCollapse(
lines: string[],
showCollapseColumn: boolean,
language: string
): {
collapsedLines: Set<number>
collapsibleLines: Set<number>
collapsibleRegions: Map<number, CollapsibleRegion>
collapsedStringLines: Set<number>
visibleLineIndices: number[]
toggleCollapse: (lineIndex: number) => void
} {
const [collapsedLines, setCollapsedLines] = useState<Set<number>>(new Set())
const collapsibleRegions = useMemo(() => {
if (!showCollapseColumn || language !== 'json') return new Map<number, CollapsibleRegion>()
return findCollapsibleRegions(lines)
}, [lines, showCollapseColumn, language])
const collapsibleLines = useMemo(() => new Set(collapsibleRegions.keys()), [collapsibleRegions])
// Track which collapsed lines are string type (need truncation, not hiding)
const collapsedStringLines = useMemo(() => {
const stringLines = new Set<number>()
for (const lineIdx of collapsedLines) {
const region = collapsibleRegions.get(lineIdx)
if (region?.type === 'string') {
stringLines.add(lineIdx)
}
}
return stringLines
}, [collapsedLines, collapsibleRegions])
const visibleLineIndices = useMemo(() => {
if (!showCollapseColumn) {
return Array.from({ length: lines.length }, (_, i) => i)
}
return computeVisibleLineIndices(lines.length, collapsedLines, collapsibleRegions)
}, [lines.length, collapsedLines, collapsibleRegions, showCollapseColumn])
const toggleCollapse = useCallback((lineIndex: number) => {
setCollapsedLines((prev) => {
const next = new Set(prev)
if (next.has(lineIndex)) {
next.delete(lineIndex)
} else {
next.add(lineIndex)
}
return next
})
}, [])
return {
collapsedLines,
collapsibleLines,
collapsibleRegions,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
}
}
/**
* Props for the CollapseButton component.
*/
interface CollapseButtonProps {
/** Whether the region is currently collapsed */
isCollapsed: boolean
/** Handler for toggle click */
onClick: () => void
}
/**
* Collapse/expand button with chevron icon.
* Rotates chevron based on collapse state.
*
* @param props - Component props
*/
const CollapseButton = memo(function CollapseButton({ isCollapsed, onClick }: CollapseButtonProps) {
return (
<button
type='button'
onClick={onClick}
className='flex h-[21px] w-[12px] cursor-pointer items-center justify-center border-none bg-transparent p-0 text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
>
<ChevronRight
className={cn(
'!h-[12px] !w-[12px] transition-transform duration-100',
!isCollapsed && 'rotate-90'
)}
/>
</button>
)
})
/**
* Props for the Code.Container component.
*/
@@ -256,28 +517,64 @@ function Placeholder({ children, gutterWidth, show, className }: CodePlaceholder
}
/**
* Props for virtualized row rendering.
* Represents a highlighted line of code.
*/
interface HighlightedLine {
/** 1-based line number */
lineNumber: number
/** Syntax-highlighted HTML content */
html: string
}
/**
* Props for virtualized row rendering.
*/
interface CodeRowProps {
/** Array of highlighted lines to render */
lines: HighlightedLine[]
/** Width of the gutter in pixels */
gutterWidth: number
/** Whether to show the line number gutter */
showGutter: boolean
/** Custom styles for the gutter */
gutterStyle?: React.CSSProperties
/** Left offset for alignment */
leftOffset: number
/** Whether to wrap long lines */
wrapText: boolean
/** Whether to show the collapse column */
showCollapseColumn: boolean
/** Set of line indices that can be collapsed */
collapsibleLines: Set<number>
/** Set of line indices that are currently collapsed */
collapsedLines: Set<number>
/** Handler for toggling collapse state */
onToggleCollapse: (lineIndex: number) => void
}
/**
* Row component for virtualized code viewer.
* Renders a single line with optional gutter and collapse button.
*
* @param props - Row component props from react-window
*/
function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
const { lines, gutterWidth, showGutter, gutterStyle, leftOffset, wrapText } = props
const {
lines,
gutterWidth,
showGutter,
gutterStyle,
leftOffset,
wrapText,
showCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse,
} = props
const line = lines[index]
const originalLineIndex = line.lineNumber - 1
const isCollapsible = showCollapseColumn && collapsibleLines.has(originalLineIndex)
const isCollapsed = collapsedLines.has(originalLineIndex)
return (
<div style={style} className={cn('flex', wrapText && 'overflow-hidden')} data-row-index={index}>
@@ -289,6 +586,19 @@ function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
{line.lineNumber}
</div>
)}
{showCollapseColumn && (
<div
className='ml-1 flex flex-shrink-0 items-start justify-end'
style={{ width: COLLAPSE_COLUMN_WIDTH }}
>
{isCollapsible && (
<CollapseButton
isCollapsed={isCollapsed}
onClick={() => onToggleCollapse(originalLineIndex)}
/>
)}
</div>
)}
<pre
className={cn(
'm-0 flex-1 pr-2 pl-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]',
@@ -302,6 +612,12 @@ function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
/**
* Applies search highlighting to a single line for virtualized rendering.
*
* @param html - The syntax-highlighted HTML string
* @param searchQuery - The search query to highlight
* @param currentMatchIndex - Index of the current match (for distinct highlighting)
* @param globalMatchOffset - Cumulative match count before this line
* @returns Object containing highlighted HTML and count of matches in this line
*/
function applySearchHighlightingToLine(
html: string,
@@ -366,6 +682,8 @@ interface CodeViewerProps {
contentRef?: React.RefObject<HTMLDivElement | null>
/** Enable virtualized rendering for large outputs (uses react-window) */
virtualized?: boolean
/** Whether to show a collapse column for JSON folding (only for json language) */
showCollapseColumn?: boolean
}
/**
@@ -422,42 +740,41 @@ function applySearchHighlighting(
.join('')
}
/**
* Counts all matches for a search query in the given code.
*
* @param code - The raw code string
* @param searchQuery - The search query
* @returns Number of matches found
*/
function countSearchMatches(code: string, searchQuery: string): number {
if (!searchQuery.trim()) return 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
const matches = code.match(regex)
return matches?.length ?? 0
}
/**
* Props for inner viewer components (with defaults already applied).
*/
type ViewerInnerProps = {
/** Code content to display */
code: string
/** Whether to show line numbers gutter */
showGutter: boolean
/** Language for syntax highlighting */
language: 'javascript' | 'json' | 'python'
/** Additional CSS classes for the container */
className?: string
/** Left padding offset in pixels */
paddingLeft: number
/** Custom styles for the gutter */
gutterStyle?: React.CSSProperties
/** Whether to wrap long lines */
wrapText: boolean
/** Search query to highlight */
searchQuery?: string
/** Index of the current active match */
currentMatchIndex: number
/** Callback when match count changes */
onMatchCountChange?: (count: number) => void
/** Ref for the content container */
contentRef?: React.RefObject<HTMLDivElement | null>
/** Whether to show collapse column for JSON folding */
showCollapseColumn: boolean
}
/**
* Virtualized code viewer implementation using react-window.
* Optimized for large outputs with efficient scrolling and dynamic row heights.
*
* @param props - Viewer props
*/
const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
code,
@@ -471,6 +788,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const listRef = useListRef(null)
@@ -481,52 +799,70 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
key: wrapText ? 'wrap' : 'nowrap',
})
const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
const lines = useMemo(() => code.split('\n'), [code])
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
const {
collapsedLines,
collapsibleLines,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
} = useJsonCollapse(lines, showCollapseColumn, language)
// Compute display lines (accounting for truncation of collapsed strings)
const displayLines = useMemo(() => {
return lines.map((line, idx) =>
collapsedStringLines.has(idx) ? truncateStringLine(line) : line
)
}, [lines, collapsedStringLines])
// Pre-compute cumulative match offsets based on DISPLAYED content (handles truncation)
const { matchOffsets, matchCount } = useMemo(() => {
if (!searchQuery?.trim()) return { matchOffsets: [], matchCount: 0 }
const offsets: number[] = []
let cumulative = 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
const visibleSet = new Set(visibleLineIndices)
for (let i = 0; i < lines.length; i++) {
offsets.push(cumulative)
// Only count matches in visible lines, using displayed (possibly truncated) content
if (visibleSet.has(i)) {
const matches = displayLines[i].match(regex)
cumulative += matches?.length ?? 0
}
}
return { matchOffsets: offsets, matchCount: cumulative }
}, [lines.length, displayLines, visibleLineIndices, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
const lines = useMemo(() => code.split('\n'), [code])
const lineCount = lines.length
const gutterWidth = useMemo(() => calculateGutterWidth(lineCount), [lineCount])
const highlightedLines = useMemo(() => {
// Only process visible lines for efficiency (not all lines)
const visibleLines = useMemo(() => {
const lang = languages[language] || languages.javascript
return lines.map((line, idx) => ({
lineNumber: idx + 1,
html: highlight(line, lang, language),
}))
}, [lines, language])
const hasSearch = searchQuery?.trim()
const matchOffsets = useMemo(() => {
if (!searchQuery?.trim()) return []
const offsets: number[] = []
let cumulative = 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
return visibleLineIndices.map((idx) => {
let html = highlight(displayLines[idx], lang, language)
for (const line of lines) {
offsets.push(cumulative)
const matches = line.match(regex)
cumulative += matches?.length ?? 0
}
return offsets
}, [lines, searchQuery])
if (hasSearch && searchQuery) {
const result = applySearchHighlightingToLine(
html,
searchQuery,
currentMatchIndex,
matchOffsets[idx]
)
html = result.html
}
const linesWithSearch = useMemo(() => {
if (!searchQuery?.trim()) return highlightedLines
return highlightedLines.map((line, idx) => {
const { html } = applySearchHighlightingToLine(
line.html,
searchQuery,
currentMatchIndex,
matchOffsets[idx]
)
return { ...line, html }
return { lineNumber: idx + 1, html }
})
}, [highlightedLines, searchQuery, currentMatchIndex, matchOffsets])
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex, matchOffsets])
useEffect(() => {
if (!searchQuery?.trim() || matchCount === 0 || !listRef.current) return
@@ -535,12 +871,15 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
for (let i = 0; i < matchOffsets.length; i++) {
const matchesInThisLine = (matchOffsets[i + 1] ?? matchCount) - matchOffsets[i]
if (currentMatchIndex >= accumulated && currentMatchIndex < accumulated + matchesInThisLine) {
listRef.current.scrollToRow({ index: i, align: 'center' })
const visibleIndex = visibleLineIndices.indexOf(i)
if (visibleIndex !== -1) {
listRef.current.scrollToRow({ index: visibleIndex, align: 'center' })
}
break
}
accumulated += matchesInThisLine
}
}, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef])
}, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef, visibleLineIndices])
useEffect(() => {
const container = containerRef.current
@@ -549,15 +888,11 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
const parent = container.parentElement
if (!parent) return
const updateHeight = () => {
setContainerHeight(parent.clientHeight)
}
const updateHeight = () => setContainerHeight(parent.clientHeight)
updateHeight()
const resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(parent)
return () => resizeObserver.disconnect()
}, [])
@@ -571,7 +906,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
if (rows.length === 0) return
return dynamicRowHeight.observeRowElements(rows)
}, [wrapText, dynamicRowHeight, linesWithSearch])
}, [wrapText, dynamicRowHeight, visibleLines])
const setRefs = useCallback(
(el: HTMLDivElement | null) => {
@@ -585,14 +920,29 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
const rowProps = useMemo(
() => ({
lines: linesWithSearch,
lines: visibleLines,
gutterWidth,
showGutter,
gutterStyle,
leftOffset: paddingLeft,
wrapText,
showCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse: toggleCollapse,
}),
[linesWithSearch, gutterWidth, showGutter, gutterStyle, paddingLeft, wrapText]
[
visibleLines,
gutterWidth,
showGutter,
gutterStyle,
paddingLeft,
wrapText,
showCollapseColumn,
collapsibleLines,
collapsedLines,
toggleCollapse,
]
)
return (
@@ -609,7 +959,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
<List
listRef={listRef}
defaultHeight={containerHeight}
rowCount={lineCount}
rowCount={visibleLines.length}
rowHeight={wrapText ? dynamicRowHeight : CODE_LINE_HEIGHT_PX}
rowComponent={CodeRow}
rowProps={rowProps}
@@ -647,6 +997,9 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
*/
/**
* Non-virtualized code viewer implementation.
* Renders all lines directly without windowing.
*
* @param props - Viewer props
*/
function ViewerInner({
code,
@@ -660,23 +1013,96 @@ function ViewerInner({
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
// Compute match count and notify parent
const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
const lines = useMemo(() => code.split('\n'), [code])
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
const {
collapsedLines,
collapsibleLines,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
} = useJsonCollapse(lines, showCollapseColumn, language)
// Compute display lines (accounting for truncation of collapsed strings)
const displayLines = useMemo(() => {
return lines.map((line, idx) =>
collapsedStringLines.has(idx) ? truncateStringLine(line) : line
)
}, [lines, collapsedStringLines])
// Pre-compute cumulative match offsets based on DISPLAYED content (handles truncation)
const { cumulativeMatches, matchCount } = useMemo(() => {
if (!searchQuery?.trim()) return { cumulativeMatches: [0], matchCount: 0 }
const cumulative: number[] = [0]
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
const visibleSet = new Set(visibleLineIndices)
for (let i = 0; i < lines.length; i++) {
const prev = cumulative[cumulative.length - 1]
// Only count matches in visible lines, using displayed content
if (visibleSet.has(i)) {
const matches = displayLines[i].match(regex)
cumulative.push(prev + (matches?.length ?? 0))
} else {
cumulative.push(prev)
}
}
return { cumulativeMatches: cumulative, matchCount: cumulative[cumulative.length - 1] }
}, [lines.length, displayLines, visibleLineIndices, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// Determine whitespace class based on wrap setting
// Pre-compute highlighted lines with search for visible indices (for gutter mode)
const highlightedVisibleLines = useMemo(() => {
const lang = languages[language] || languages.javascript
if (!searchQuery?.trim()) {
return visibleLineIndices.map((idx) => ({
lineNumber: idx + 1,
html: highlight(displayLines[idx], lang, language) || '&nbsp;',
}))
}
return visibleLineIndices.map((idx) => {
let html = highlight(displayLines[idx], lang, language)
const matchCounter = { count: cumulativeMatches[idx] }
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
return { lineNumber: idx + 1, html: html || '&nbsp;' }
})
}, [
displayLines,
language,
visibleLineIndices,
searchQuery,
currentMatchIndex,
cumulativeMatches,
])
// Pre-compute simple highlighted code (for no-gutter mode)
const highlightedCode = useMemo(() => {
const lang = languages[language] || languages.javascript
const visibleCode = visibleLineIndices.map((idx) => displayLines[idx]).join('\n')
let html = highlight(visibleCode, lang, language)
if (searchQuery?.trim()) {
const matchCounter = { count: 0 }
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
}
return html
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
const collapseColumnWidth = showCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
// Special rendering path: when wrapping with gutter, render per-line rows so gutter stays aligned.
if (showGutter && wrapText) {
const lines = code.split('\n')
const gutterWidth = calculateGutterWidth(lines.length)
const matchCounter = { count: 0 }
// Grid-based rendering for gutter alignment (works with wrap)
if (showGutter) {
return (
<Container className={className}>
<Content className='code-editor-theme' editorRef={contentRef}>
@@ -686,37 +1112,40 @@ function ViewerInner({
paddingTop: '8px',
paddingBottom: '8px',
display: 'grid',
gridTemplateColumns: `${gutterWidth}px 1fr`,
gridTemplateColumns: showCollapseColumn
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
: `${gutterWidth}px 1fr`,
}}
>
{lines.map((line, idx) => {
let perLineHighlighted = highlight(
line,
languages[language] || languages.javascript,
language
)
// Apply search highlighting if query exists
if (searchQuery?.trim()) {
perLineHighlighted = applySearchHighlighting(
perLineHighlighted,
searchQuery,
currentMatchIndex,
matchCounter
)
}
{highlightedVisibleLines.map(({ lineNumber, html }) => {
const idx = lineNumber - 1
const isCollapsible = collapsibleLines.has(idx)
const isCollapsed = collapsedLines.has(idx)
return (
<Fragment key={idx}>
<div
className='select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[#a8a8a8]'
style={{ transform: 'translateY(0.25px)', ...gutterStyle }}
style={gutterStyle}
>
{idx + 1}
{lineNumber}
</div>
{showCollapseColumn && (
<div className='ml-1 flex items-start justify-end'>
{isCollapsible && (
<CollapseButton
isCollapsed={isCollapsed}
onClick={() => toggleCollapse(idx)}
/>
)}
</div>
)}
<pre
className='m-0 min-w-0 whitespace-pre-wrap break-words pr-2 pl-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
dangerouslySetInnerHTML={{ __html: perLineHighlighted || '&nbsp;' }}
className={cn(
'm-0 min-w-0 pr-2 pl-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]',
whitespaceClass
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</Fragment>
)
@@ -727,69 +1156,16 @@ function ViewerInner({
)
}
// Apply syntax highlighting
let highlightedCode = highlight(code, languages[language] || languages.javascript, language)
// Apply search highlighting if query exists
if (searchQuery?.trim()) {
const matchCounter = { count: 0 }
highlightedCode = applySearchHighlighting(
highlightedCode,
searchQuery,
currentMatchIndex,
matchCounter
)
}
if (!showGutter) {
// Simple display without gutter
return (
<Container className={className}>
<Content className='code-editor-theme' editorRef={contentRef}>
<pre
className={cn(
whitespaceClass,
'p-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
)}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</Content>
</Container>
)
}
// Calculate line numbers
const lineCount = code.split('\n').length
const gutterWidth = calculateGutterWidth(lineCount)
// Render line numbers
const lineNumbers = []
for (let i = 1; i <= lineCount; i++) {
lineNumbers.push(
<div
key={i}
className='text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[#a8a8a8]'
>
{i}
</div>
)
}
// Simple display without gutter
return (
<Container className={className}>
<Gutter width={gutterWidth} style={{ left: `${paddingLeft}px`, ...gutterStyle }}>
{lineNumbers}
</Gutter>
<Content
className='code-editor-theme'
paddingLeft={`${gutterWidth + paddingLeft}px`}
editorRef={contentRef}
>
<Content className='code-editor-theme' editorRef={contentRef}>
<pre
className={cn(
whitespaceClass,
'p-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
)}
style={{ paddingLeft: paddingLeft > 0 ? paddingLeft : undefined }}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</Content>
@@ -800,6 +1176,9 @@ function ViewerInner({
/**
* Readonly code viewer with optional gutter and syntax highlighting.
* Routes to either standard or virtualized implementation based on the `virtualized` prop.
*
* @param props - Component props
* @returns React component
*/
function Viewer({
code,
@@ -814,6 +1193,7 @@ function Viewer({
onMatchCountChange,
contentRef,
virtualized = false,
showCollapseColumn = false,
}: CodeViewerProps) {
const innerProps: ViewerInnerProps = {
code,
@@ -827,6 +1207,7 @@ function Viewer({
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}
return virtualized ? <VirtualizedViewerInner {...innerProps} /> : <ViewerInner {...innerProps} />

View File

@@ -5,9 +5,8 @@ import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/core/utils/cn'
/**
* Custom switch component with thin track design.
* Track: 28px width, 6px height, 20px border-radius
* Thumb: 14px diameter circle that overlaps the track
* Switch component styled to match Sim's design system.
* Uses brand color for checked state, neutral border for unchecked.
*/
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -16,21 +15,13 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root
disabled={disabled}
className={cn(
'peer inline-flex h-[17px] w-[30px] shrink-0 cursor-pointer items-center rounded-[20px] transition-colors focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'bg-[var(--border-1)] data-[state=checked]:bg-[var(--text-primary)]',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--brand-tertiary-2)] data-[disabled]:opacity-50',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-[14px] w-[14px] rounded-full shadow-sm ring-0 transition-transform',
'bg-[var(--white)]',
'data-[state=checked]:translate-x-[14px] data-[state=unchecked]:translate-x-[2px]'
)}
/>
<SwitchPrimitives.Thumb className='pointer-events-none block h-4 w-4 rounded-full bg-[var(--white)] shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5' />
</SwitchPrimitives.Root>
))

View File

@@ -275,6 +275,26 @@ export function isTriggerBlockType(blockType: string | undefined): boolean {
return blockType !== undefined && (TRIGGER_BLOCK_TYPES as readonly string[]).includes(blockType)
}
/**
* Determines if a block behaves as a trigger based on its metadata and config.
* This is used for execution flow decisions where trigger-like behavior matters.
*
* A block is considered trigger-like if:
* - Its category is 'triggers'
* - It has triggerMode enabled
* - It's a starter block (legacy entry point)
*/
export function isTriggerBehavior(block: {
metadata?: { category?: string; id?: string }
config?: { params?: { triggerMode?: boolean } }
}): boolean {
return (
block.metadata?.category === 'triggers' ||
block.config?.params?.triggerMode === true ||
block.metadata?.id === BlockType.STARTER
)
}
export function isMetadataOnlyBlockType(blockType: string | undefined): boolean {
return (
blockType !== undefined && (METADATA_ONLY_BLOCK_TYPES as readonly string[]).includes(blockType)

View File

@@ -11,6 +11,8 @@ import {
DEFAULTS,
EDGE,
isSentinelBlockType,
isTriggerBehavior,
isWorkflowBlockType,
} from '@/executor/constants'
import type { DAGNode } from '@/executor/dag/builder'
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
@@ -153,8 +155,8 @@ export class BlockExecutor {
this.state.setBlockOutput(node.id, normalizedOutput, duration)
if (!isSentinel) {
const filteredOutput = this.filterOutputForLog(block, normalizedOutput)
this.callOnBlockComplete(ctx, node, block, resolvedInputs, filteredOutput, duration)
const displayOutput = this.filterOutputForDisplay(block, normalizedOutput)
this.callOnBlockComplete(ctx, node, block, resolvedInputs, displayOutput, duration)
}
return normalizedOutput
@@ -244,7 +246,8 @@ export class BlockExecutor {
)
if (!isSentinel) {
this.callOnBlockComplete(ctx, node, block, input, errorOutput, duration)
const displayOutput = this.filterOutputForDisplay(block, errorOutput)
this.callOnBlockComplete(ctx, node, block, input, displayOutput, duration)
}
const hasErrorPort = this.hasErrorPortEdge(node)
@@ -336,7 +339,9 @@ export class BlockExecutor {
block: SerializedBlock,
output: NormalizedBlockOutput
): NormalizedBlockOutput {
if (block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP) {
const blockType = block.metadata?.id
if (blockType === BlockType.HUMAN_IN_THE_LOOP) {
const filtered: NormalizedBlockOutput = {}
for (const [key, value] of Object.entries(output)) {
if (key.startsWith('_')) continue
@@ -346,12 +351,7 @@ export class BlockExecutor {
return filtered
}
const isTrigger =
block.metadata?.category === 'triggers' ||
block.config?.params?.triggerMode === true ||
block.metadata?.id === BlockType.STARTER
if (isTrigger) {
if (isTriggerBehavior(block)) {
const filtered: NormalizedBlockOutput = {}
const internalKeys = ['webhook', 'workflowId']
for (const [key, value] of Object.entries(output)) {
@@ -364,6 +364,22 @@ export class BlockExecutor {
return output
}
private filterOutputForDisplay(
block: SerializedBlock,
output: NormalizedBlockOutput
): NormalizedBlockOutput {
const filtered = this.filterOutputForLog(block, output)
if (isWorkflowBlockType(block.metadata?.id)) {
const { childTraceSpans: _, ...displayOutput } = filtered as {
childTraceSpans?: unknown
} & Record<string, unknown>
return displayOutput
}
return filtered
}
private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void {
const blockId = node.id
const blockName = block.metadata?.name ?? blockId

View File

@@ -773,6 +773,176 @@ describe('EdgeManager', () => {
})
})
describe('Multiple error ports to same target', () => {
it('should mark target ready when one source errors and another succeeds', () => {
// This tests the case where a node has multiple incoming error edges
// from different sources. When one source errors (activating its error edge)
// and another source succeeds (deactivating its error edge), the target
// should become ready after both sources complete.
//
// Workflow 1 (errors) ─── error ───┐
// ├──→ Error Handler
// Workflow 7 (succeeds) ─ error ───┘
const workflow1Id = 'workflow-1'
const workflow7Id = 'workflow-7'
const errorHandlerId = 'error-handler'
const workflow1Node = createMockNode(workflow1Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const workflow7Node = createMockNode(workflow7Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
const nodes = new Map<string, DAGNode>([
[workflow1Id, workflow1Node],
[workflow7Id, workflow7Node],
[errorHandlerId, errorHandlerNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Workflow 1 errors first - error edge activates
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
error: 'Something went wrong',
})
// Error handler should NOT be ready yet (waiting for workflow 7)
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
// Workflow 7 succeeds - error edge deactivates
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
result: 'success',
})
// Error handler SHOULD be ready now (workflow 1's error edge activated)
expect(readyAfterWorkflow7).toContain(errorHandlerId)
})
it('should mark target ready when first source succeeds then second errors', () => {
// Opposite order: first source succeeds, then second errors
const workflow1Id = 'workflow-1'
const workflow7Id = 'workflow-7'
const errorHandlerId = 'error-handler'
const workflow1Node = createMockNode(workflow1Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const workflow7Node = createMockNode(workflow7Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
const nodes = new Map<string, DAGNode>([
[workflow1Id, workflow1Node],
[workflow7Id, workflow7Node],
[errorHandlerId, errorHandlerNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Workflow 1 succeeds first - error edge deactivates
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
result: 'success',
})
// Error handler should NOT be ready yet (waiting for workflow 7)
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
// Workflow 7 errors - error edge activates
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
error: 'Something went wrong',
})
// Error handler SHOULD be ready now (workflow 7's error edge activated)
expect(readyAfterWorkflow7).toContain(errorHandlerId)
})
it('should NOT mark target ready when all sources succeed (no errors)', () => {
// When neither source errors, the error handler should NOT run
const workflow1Id = 'workflow-1'
const workflow7Id = 'workflow-7'
const errorHandlerId = 'error-handler'
const workflow1Node = createMockNode(workflow1Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const workflow7Node = createMockNode(workflow7Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
const nodes = new Map<string, DAGNode>([
[workflow1Id, workflow1Node],
[workflow7Id, workflow7Node],
[errorHandlerId, errorHandlerNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Both workflows succeed - both error edges deactivate
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
result: 'success',
})
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
result: 'success',
})
// Error handler should NOT be ready (no errors occurred)
expect(readyAfterWorkflow7).not.toContain(errorHandlerId)
})
it('should mark target ready when both sources error', () => {
// When both sources error, the error handler should run
const workflow1Id = 'workflow-1'
const workflow7Id = 'workflow-7'
const errorHandlerId = 'error-handler'
const workflow1Node = createMockNode(workflow1Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const workflow7Node = createMockNode(workflow7Id, [
{ target: errorHandlerId, sourceHandle: 'error' },
])
const errorHandlerNode = createMockNode(errorHandlerId, [], [workflow1Id, workflow7Id])
const nodes = new Map<string, DAGNode>([
[workflow1Id, workflow1Node],
[workflow7Id, workflow7Node],
[errorHandlerId, errorHandlerNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Workflow 1 errors
const readyAfterWorkflow1 = edgeManager.processOutgoingEdges(workflow1Node, {
error: 'Error 1',
})
expect(readyAfterWorkflow1).not.toContain(errorHandlerId)
// Workflow 7 errors
const readyAfterWorkflow7 = edgeManager.processOutgoingEdges(workflow7Node, {
error: 'Error 2',
})
// Error handler SHOULD be ready (both edges activated)
expect(readyAfterWorkflow7).toContain(errorHandlerId)
})
})
describe('Chained conditions', () => {
it('should handle sequential conditions (condition1 → condition2)', () => {
const condition1Id = 'condition-1'

View File

@@ -8,6 +8,7 @@ const logger = createLogger('EdgeManager')
export class EdgeManager {
private deactivatedEdges = new Set<string>()
private nodesWithActivatedEdge = new Set<string>()
constructor(private dag: DAG) {}
@@ -35,6 +36,11 @@ export class EdgeManager {
activatedTargets.push(edge.target)
}
// Track nodes that have received at least one activated edge
for (const targetId of activatedTargets) {
this.nodesWithActivatedEdge.add(targetId)
}
const cascadeTargets = new Set<string>()
for (const { target, handle } of edgesToDeactivate) {
this.deactivateEdgeAndDescendants(node.id, target, handle, cascadeTargets)
@@ -71,6 +77,18 @@ export class EdgeManager {
}
}
// Check if any deactivation targets that previously received an activated edge are now ready
for (const { target } of edgesToDeactivate) {
if (
!readyNodes.includes(target) &&
!activatedTargets.includes(target) &&
this.nodesWithActivatedEdge.has(target) &&
this.isTargetReady(target)
) {
readyNodes.push(target)
}
}
return readyNodes
}
@@ -90,6 +108,7 @@ export class EdgeManager {
clearDeactivatedEdges(): void {
this.deactivatedEdges.clear()
this.nodesWithActivatedEdge.clear()
}
/**
@@ -108,6 +127,10 @@ export class EdgeManager {
for (const edgeKey of edgesToRemove) {
this.deactivatedEdges.delete(edgeKey)
}
// Also clear activated edge tracking for these nodes
for (const nodeId of nodeIds) {
this.nodesWithActivatedEdge.delete(nodeId)
}
}
private isTargetReady(targetId: string): boolean {
@@ -210,7 +233,11 @@ export class EdgeManager {
cascadeTargets?.add(targetId)
}
if (this.hasActiveIncomingEdges(targetNode, edgeKey)) {
// Don't cascade if node has active incoming edges OR has received an activated edge
if (
this.hasActiveIncomingEdges(targetNode, edgeKey) ||
this.nodesWithActivatedEdge.has(targetId)
) {
return
}

View File

@@ -259,17 +259,6 @@ export class ExecutionEngine {
}
private initializeQueue(triggerBlockId?: string): void {
// Run-from-block mode: start directly from specified block
if (this.context.runFromBlockContext) {
const { startBlockId } = this.context.runFromBlockContext
logger.info('Initializing queue for run-from-block mode', {
startBlockId,
dirtySetSize: this.context.runFromBlockContext.dirtySet.size,
})
this.addToQueue(startBlockId)
return
}
const pendingBlocks = this.context.metadata.pendingBlocks
const remainingEdges = (this.context.metadata as any).remainingEdges

View File

@@ -5,21 +5,12 @@ import { BlockExecutor } from '@/executor/execution/block-executor'
import { EdgeManager } from '@/executor/execution/edge-manager'
import { ExecutionEngine } from '@/executor/execution/engine'
import { ExecutionState } from '@/executor/execution/state'
import type {
ContextExtensions,
SerializableExecutionState,
WorkflowInput,
} from '@/executor/execution/types'
import type { ContextExtensions, WorkflowInput } from '@/executor/execution/types'
import { createBlockHandlers } from '@/executor/handlers/registry'
import { LoopOrchestrator } from '@/executor/orchestrators/loop'
import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types'
import {
computeDirtySet,
type RunFromBlockContext,
validateRunFromBlock,
} from '@/executor/utils/run-from-block'
import {
buildResolutionFromBlock,
buildStartBlockOutput,
@@ -98,126 +89,17 @@ export class DAGExecutor {
}
}
/**
* Execute workflow starting from a specific block, using cached outputs
* for all upstream/unaffected blocks from the source snapshot.
*
* This implements Jupyter notebook-style execution where:
* - The start block and all downstream blocks are re-executed
* - Upstream blocks retain their cached outputs from the source snapshot
* - The result is a merged execution state
*
* @param workflowId - The workflow ID
* @param startBlockId - The block to start execution from
* @param sourceSnapshot - The execution state from a previous run
* @returns Merged execution result with cached + fresh outputs
*/
async executeFromBlock(
workflowId: string,
startBlockId: string,
sourceSnapshot: SerializableExecutionState
): Promise<ExecutionResult> {
// Build full DAG (no trigger constraint - we need all blocks for validation)
const dag = this.dagBuilder.build(this.workflow)
// Validate the start block
const executedBlocks = new Set(sourceSnapshot.executedBlocks)
const validation = validateRunFromBlock(startBlockId, dag, executedBlocks)
if (!validation.valid) {
throw new Error(validation.error)
}
// Compute dirty set (blocks that will be re-executed)
const dirtySet = computeDirtySet(dag, startBlockId)
logger.info('Executing from block', {
workflowId,
startBlockId,
dirtySetSize: dirtySet.size,
totalBlocks: dag.nodes.size,
dirtyBlocks: Array.from(dirtySet),
})
// For convergent blocks in the dirty set, remove incoming edges from non-dirty sources.
// This ensures that a dirty block waiting on multiple inputs doesn't wait for non-dirty
// upstream blocks (whose outputs are already cached).
for (const nodeId of dirtySet) {
const node = dag.nodes.get(nodeId)
if (!node) continue
const nonDirtyIncoming: string[] = []
for (const sourceId of node.incomingEdges) {
if (!dirtySet.has(sourceId)) {
nonDirtyIncoming.push(sourceId)
}
}
for (const sourceId of nonDirtyIncoming) {
node.incomingEdges.delete(sourceId)
logger.debug('Removed non-dirty incoming edge for run-from-block', {
nodeId,
sourceId,
})
}
}
// Create context with snapshot state + runFromBlockContext
const runFromBlockContext = { startBlockId, dirtySet }
const { context, state } = this.createExecutionContext(workflowId, undefined, {
snapshotState: sourceSnapshot,
runFromBlockContext,
})
// Setup orchestrators and engine (same as execute())
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
const loopOrchestrator = new LoopOrchestrator(dag, state, resolver)
loopOrchestrator.setContextExtensions(this.contextExtensions)
const parallelOrchestrator = new ParallelOrchestrator(dag, state)
parallelOrchestrator.setResolver(resolver)
parallelOrchestrator.setContextExtensions(this.contextExtensions)
const allHandlers = createBlockHandlers()
const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state)
const edgeManager = new EdgeManager(dag)
loopOrchestrator.setEdgeManager(edgeManager)
const nodeOrchestrator = new NodeExecutionOrchestrator(
dag,
state,
blockExecutor,
loopOrchestrator,
parallelOrchestrator
)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
// Run and return result
return await engine.run()
}
private createExecutionContext(
workflowId: string,
triggerBlockId?: string,
overrides?: {
snapshotState?: SerializableExecutionState
runFromBlockContext?: RunFromBlockContext
}
triggerBlockId?: string
): { context: ExecutionContext; state: ExecutionState } {
const snapshotState = overrides?.snapshotState ?? this.contextExtensions.snapshotState
const snapshotState = this.contextExtensions.snapshotState
const blockStates = snapshotState?.blockStates
? new Map(Object.entries(snapshotState.blockStates))
: new Map<string, BlockState>()
let executedBlocks = snapshotState?.executedBlocks
const executedBlocks = snapshotState?.executedBlocks
? new Set(snapshotState.executedBlocks)
: new Set<string>()
// In run-from-block mode, clear the executed status for dirty blocks so they can be re-executed
if (overrides?.runFromBlockContext) {
const { dirtySet } = overrides.runFromBlockContext
executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id)))
logger.info('Cleared executed status for dirty blocks', {
dirtySetSize: dirtySet.size,
remainingExecutedBlocks: executedBlocks.size,
})
}
const state = new ExecutionState(blockStates, executedBlocks)
const context: ExecutionContext = {
@@ -287,7 +169,6 @@ export class DAGExecutor {
abortSignal: this.contextExtensions.abortSignal,
includeFileBase64: this.contextExtensions.includeFileBase64,
base64MaxBytes: this.contextExtensions.base64MaxBytes,
runFromBlockContext: overrides?.runFromBlockContext,
}
if (this.contextExtensions.resumeFromSnapshot) {
@@ -312,12 +193,6 @@ export class DAGExecutor {
pendingBlocks: context.metadata.pendingBlocks,
skipStarterBlockInit: true,
})
} else if (overrides?.runFromBlockContext) {
// In run-from-block mode, skip starter block initialization
// All block states come from the snapshot
logger.info('Run-from-block mode: skipping starter block initialization', {
startBlockId: overrides.runFromBlockContext.startBlockId,
})
} else {
this.initializeStarterBlock(context, state, triggerBlockId)
}

View File

@@ -1,6 +1,5 @@
import type { Edge } from 'reactflow'
import type { BlockLog, BlockState, NormalizedBlockOutput } from '@/executor/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SubflowType } from '@/stores/workflows/workflow/types'
export interface ExecutionMetadata {
@@ -106,12 +105,6 @@ export interface ContextExtensions {
output: { input?: any; output: NormalizedBlockOutput; executionTime: number },
iterationContext?: IterationContext
) => Promise<void>
/**
* Run-from-block configuration. When provided, executor runs in partial
* execution mode starting from the specified block.
*/
runFromBlockContext?: RunFromBlockContext
}
export interface WorkflowInput {

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { BlockType } from '@/executor/constants'
import { BlockType, isTriggerBehavior } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
@@ -7,15 +7,7 @@ const logger = createLogger('TriggerBlockHandler')
export class TriggerBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
if (block.metadata?.id === BlockType.STARTER) {
return true
}
const isTriggerCategory = block.metadata?.category === 'triggers'
const hasTriggerMode = block.config?.params?.triggerMode === true
return isTriggerCategory || hasTriggerMode
return isTriggerBehavior(block)
}
async execute(

View File

@@ -31,20 +31,7 @@ export class NodeExecutionOrchestrator {
throw new Error(`Node not found in DAG: ${nodeId}`)
}
// In run-from-block mode, skip execution for non-dirty blocks and return cached output
if (ctx.runFromBlockContext && !ctx.runFromBlockContext.dirtySet.has(nodeId)) {
const cachedOutput = this.state.getBlockOutput(nodeId) || {}
logger.debug('Skipping non-dirty block in run-from-block mode', { nodeId })
return {
nodeId,
output: cachedOutput,
isFinalOutput: false,
}
}
// Skip hasExecuted check for dirty blocks in run-from-block mode - they need to be re-executed
const isDirtyBlock = ctx.runFromBlockContext?.dirtySet.has(nodeId) ?? false
if (!isDirtyBlock && this.state.hasExecuted(nodeId)) {
if (this.state.hasExecuted(nodeId)) {
const output = this.state.getBlockOutput(nodeId) || {}
return {
nodeId,

View File

@@ -1,7 +1,6 @@
import type { TraceSpan } from '@/lib/logs/types'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import type { BlockOutput } from '@/blocks/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
export interface UserFile {
@@ -251,12 +250,6 @@ export interface ExecutionContext {
* will not have their base64 content fetched.
*/
base64MaxBytes?: number
/**
* Context for "run from block" mode. When present, only blocks in dirtySet
* will be executed; others return cached outputs from the source snapshot.
*/
runFromBlockContext?: RunFromBlockContext
}
export interface ExecutionResult {

View File

@@ -1,4 +1,5 @@
import { normalizeName } from '@/executor/constants'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { OutputSchema } from '@/executor/utils/block-reference'
import type { SerializedBlock } from '@/serializer/types'
@@ -11,25 +12,73 @@ export interface BlockDataCollection {
blockOutputSchemas: Record<string, OutputSchema>
}
/**
* Block types where inputFormat fields should be merged into outputs schema.
* These are blocks where users define custom fields via inputFormat that become
* valid output paths (e.g., <start.myField>, <webhook1.customField>, <hitl1.resumeField>).
*
* Note: This includes non-trigger blocks like 'starter' and 'human_in_the_loop' which
* have category 'blocks' but still need their inputFormat exposed as outputs.
*/
const BLOCKS_WITH_INPUT_FORMAT_OUTPUTS = [
'start_trigger',
'starter',
'api_trigger',
'input_trigger',
'generic_webhook',
'human_in_the_loop',
] as const
function getInputFormatFields(block: SerializedBlock): OutputSchema {
const inputFormat = normalizeInputFormatValue(block.config?.params?.inputFormat)
if (inputFormat.length === 0) {
return {}
}
const schema: OutputSchema = {}
for (const field of inputFormat) {
if (!field.name) continue
schema[field.name] = {
type: (field.type || 'any') as 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any',
}
}
return schema
}
export function getBlockSchema(
block: SerializedBlock,
toolConfig?: ToolConfig
): OutputSchema | undefined {
const isTrigger =
block.metadata?.category === 'triggers' ||
(block.config?.params as Record<string, unknown> | undefined)?.triggerMode === true
const blockType = block.metadata?.id
// For blocks that expose inputFormat as outputs, always merge them
// This includes both triggers (start_trigger, generic_webhook) and
// non-triggers (starter, human_in_the_loop) that have inputFormat
if (
blockType &&
BLOCKS_WITH_INPUT_FORMAT_OUTPUTS.includes(
blockType as (typeof BLOCKS_WITH_INPUT_FORMAT_OUTPUTS)[number]
)
) {
const baseOutputs = (block.outputs as OutputSchema) || {}
const inputFormatFields = getInputFormatFields(block)
const merged = { ...baseOutputs, ...inputFormatFields }
if (Object.keys(merged).length > 0) {
return merged
}
}
const isTrigger = isTriggerBehavior(block)
// Triggers use saved outputs (defines the trigger payload schema)
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}
// When a tool is selected, tool outputs are the source of truth
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
return toolConfig.outputs as OutputSchema
}
// Fallback to saved outputs for blocks without tools
if (block.outputs && Object.keys(block.outputs).length > 0) {
return block.outputs as OutputSchema
}

View File

@@ -1,336 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
import type { SerializedLoop, SerializedParallel } from '@/serializer/types'
import { computeDirtySet, validateRunFromBlock } from '@/executor/utils/run-from-block'
/**
* Helper to create a DAG node for testing
*/
function createNode(
id: string,
outgoingEdges: Array<{ target: string; sourceHandle?: string }> = [],
metadata: Partial<NodeMetadata> = {}
): DAGNode {
const edges = new Map<string, DAGEdge>()
for (const edge of outgoingEdges) {
edges.set(edge.target, { target: edge.target, sourceHandle: edge.sourceHandle })
}
return {
id,
block: {
id,
position: { x: 0, y: 0 },
config: { tool: 'test', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'test', name: `block-${id}`, category: 'tools' },
enabled: true,
},
incomingEdges: new Set<string>(),
outgoingEdges: edges,
metadata: {
isParallelBranch: false,
isLoopNode: false,
isSentinel: false,
...metadata,
},
}
}
/**
* Helper to create a DAG for testing
*/
function createDAG(nodes: DAGNode[]): DAG {
const nodeMap = new Map<string, DAGNode>()
for (const node of nodes) {
nodeMap.set(node.id, node)
}
// Set up incoming edges based on outgoing edges
for (const node of nodes) {
for (const [, edge] of node.outgoingEdges) {
const targetNode = nodeMap.get(edge.target)
if (targetNode) {
targetNode.incomingEdges.add(node.id)
}
}
}
return {
nodes: nodeMap,
loopConfigs: new Map<string, SerializedLoop>(),
parallelConfigs: new Map<string, SerializedParallel>(),
}
}
describe('computeDirtySet', () => {
it('includes start block in dirty set', () => {
const dag = createDAG([createNode('A'), createNode('B'), createNode('C')])
const dirtySet = computeDirtySet(dag, 'B')
expect(dirtySet.has('B')).toBe(true)
})
it('includes all downstream blocks in linear workflow', () => {
// A → B → C → D
const dag = createDAG([
createNode('A', [{ target: 'B' }]),
createNode('B', [{ target: 'C' }]),
createNode('C', [{ target: 'D' }]),
createNode('D'),
])
const dirtySet = computeDirtySet(dag, 'B')
expect(dirtySet.has('A')).toBe(false)
expect(dirtySet.has('B')).toBe(true)
expect(dirtySet.has('C')).toBe(true)
expect(dirtySet.has('D')).toBe(true)
expect(dirtySet.size).toBe(3)
})
it('handles branching paths', () => {
// A → B → C
// ↓
// D → E
const dag = createDAG([
createNode('A', [{ target: 'B' }]),
createNode('B', [{ target: 'C' }, { target: 'D' }]),
createNode('C'),
createNode('D', [{ target: 'E' }]),
createNode('E'),
])
const dirtySet = computeDirtySet(dag, 'B')
expect(dirtySet.has('A')).toBe(false)
expect(dirtySet.has('B')).toBe(true)
expect(dirtySet.has('C')).toBe(true)
expect(dirtySet.has('D')).toBe(true)
expect(dirtySet.has('E')).toBe(true)
expect(dirtySet.size).toBe(4)
})
it('handles convergence points', () => {
// A → C
// B → C → D
const dag = createDAG([
createNode('A', [{ target: 'C' }]),
createNode('B', [{ target: 'C' }]),
createNode('C', [{ target: 'D' }]),
createNode('D'),
])
// Run from A: should include A, C, D (but not B)
const dirtySet = computeDirtySet(dag, 'A')
expect(dirtySet.has('A')).toBe(true)
expect(dirtySet.has('B')).toBe(false)
expect(dirtySet.has('C')).toBe(true)
expect(dirtySet.has('D')).toBe(true)
expect(dirtySet.size).toBe(3)
})
it('handles diamond pattern', () => {
// B
// ↗ ↘
// A D
// ↘ ↗
// C
const dag = createDAG([
createNode('A', [{ target: 'B' }, { target: 'C' }]),
createNode('B', [{ target: 'D' }]),
createNode('C', [{ target: 'D' }]),
createNode('D'),
])
const dirtySet = computeDirtySet(dag, 'A')
expect(dirtySet.has('A')).toBe(true)
expect(dirtySet.has('B')).toBe(true)
expect(dirtySet.has('C')).toBe(true)
expect(dirtySet.has('D')).toBe(true)
expect(dirtySet.size).toBe(4)
})
it('stops at graph boundaries', () => {
// A → B C → D (disconnected)
const dag = createDAG([
createNode('A', [{ target: 'B' }]),
createNode('B'),
createNode('C', [{ target: 'D' }]),
createNode('D'),
])
const dirtySet = computeDirtySet(dag, 'A')
expect(dirtySet.has('A')).toBe(true)
expect(dirtySet.has('B')).toBe(true)
expect(dirtySet.has('C')).toBe(false)
expect(dirtySet.has('D')).toBe(false)
expect(dirtySet.size).toBe(2)
})
it('handles single node workflow', () => {
const dag = createDAG([createNode('A')])
const dirtySet = computeDirtySet(dag, 'A')
expect(dirtySet.has('A')).toBe(true)
expect(dirtySet.size).toBe(1)
})
it('handles node not in DAG gracefully', () => {
const dag = createDAG([createNode('A'), createNode('B')])
const dirtySet = computeDirtySet(dag, 'nonexistent')
// Should just contain the start block ID even if not found
expect(dirtySet.has('nonexistent')).toBe(true)
expect(dirtySet.size).toBe(1)
})
it('includes convergent block when running from one branch of parallel', () => {
// Parallel branches converging:
// A → B → D
// A → C → D
// Running from B should include B and D (but not A or C)
const dag = createDAG([
createNode('A', [{ target: 'B' }, { target: 'C' }]),
createNode('B', [{ target: 'D' }]),
createNode('C', [{ target: 'D' }]),
createNode('D'),
])
const dirtySet = computeDirtySet(dag, 'B')
expect(dirtySet.has('A')).toBe(false)
expect(dirtySet.has('B')).toBe(true)
expect(dirtySet.has('C')).toBe(false)
expect(dirtySet.has('D')).toBe(true)
expect(dirtySet.size).toBe(2)
})
it('handles running from convergent block itself (all upstream non-dirty)', () => {
// A → C
// B → C
// Running from C should only include C
const dag = createDAG([
createNode('A', [{ target: 'C' }]),
createNode('B', [{ target: 'C' }]),
createNode('C', [{ target: 'D' }]),
createNode('D'),
])
const dirtySet = computeDirtySet(dag, 'C')
expect(dirtySet.has('A')).toBe(false)
expect(dirtySet.has('B')).toBe(false)
expect(dirtySet.has('C')).toBe(true)
expect(dirtySet.has('D')).toBe(true)
expect(dirtySet.size).toBe(2)
})
it('handles deep downstream chains', () => {
// A → B → C → D → E → F
// Running from C should include C, D, E, F
const dag = createDAG([
createNode('A', [{ target: 'B' }]),
createNode('B', [{ target: 'C' }]),
createNode('C', [{ target: 'D' }]),
createNode('D', [{ target: 'E' }]),
createNode('E', [{ target: 'F' }]),
createNode('F'),
])
const dirtySet = computeDirtySet(dag, 'C')
expect(dirtySet.has('A')).toBe(false)
expect(dirtySet.has('B')).toBe(false)
expect(dirtySet.has('C')).toBe(true)
expect(dirtySet.has('D')).toBe(true)
expect(dirtySet.has('E')).toBe(true)
expect(dirtySet.has('F')).toBe(true)
expect(dirtySet.size).toBe(4)
})
})
describe('validateRunFromBlock', () => {
it('accepts valid block', () => {
const dag = createDAG([createNode('A'), createNode('B')])
const executedBlocks = new Set(['A', 'B'])
const result = validateRunFromBlock('A', dag, executedBlocks)
expect(result.valid).toBe(true)
expect(result.error).toBeUndefined()
})
it('rejects block not found in DAG', () => {
const dag = createDAG([createNode('A')])
const executedBlocks = new Set(['A', 'B'])
const result = validateRunFromBlock('B', dag, executedBlocks)
expect(result.valid).toBe(false)
expect(result.error).toContain('Block not found')
})
it('rejects blocks inside loops', () => {
const dag = createDAG([createNode('A', [], { isLoopNode: true, loopId: 'loop-1' })])
const executedBlocks = new Set(['A'])
const result = validateRunFromBlock('A', dag, executedBlocks)
expect(result.valid).toBe(false)
expect(result.error).toContain('inside loop')
expect(result.error).toContain('loop-1')
})
it('rejects blocks inside parallels', () => {
const dag = createDAG([createNode('A', [], { isParallelBranch: true, parallelId: 'parallel-1' })])
const executedBlocks = new Set(['A'])
const result = validateRunFromBlock('A', dag, executedBlocks)
expect(result.valid).toBe(false)
expect(result.error).toContain('inside parallel')
expect(result.error).toContain('parallel-1')
})
it('rejects sentinel nodes', () => {
const dag = createDAG([createNode('A', [], { isSentinel: true, sentinelType: 'start' })])
const executedBlocks = new Set(['A'])
const result = validateRunFromBlock('A', dag, executedBlocks)
expect(result.valid).toBe(false)
expect(result.error).toContain('sentinel')
})
it('rejects unexecuted blocks', () => {
const dag = createDAG([createNode('A'), createNode('B')])
const executedBlocks = new Set(['A']) // B was not executed
const result = validateRunFromBlock('B', dag, executedBlocks)
expect(result.valid).toBe(false)
expect(result.error).toContain('was not executed')
})
it('accepts regular executed block', () => {
const dag = createDAG([
createNode('trigger', [{ target: 'A' }]),
createNode('A', [{ target: 'B' }]),
createNode('B'),
])
const executedBlocks = new Set(['trigger', 'A', 'B'])
const result = validateRunFromBlock('A', dag, executedBlocks)
expect(result.valid).toBe(true)
})
})

View File

@@ -1,110 +0,0 @@
import { createLogger } from '@sim/logger'
import type { DAG } from '@/executor/dag/builder'
const logger = createLogger('run-from-block')
/**
* Result of validating a block for run-from-block execution.
*/
export interface RunFromBlockValidation {
valid: boolean
error?: string
}
/**
* Context for run-from-block execution mode.
*/
export interface RunFromBlockContext {
/** The block ID to start execution from */
startBlockId: string
/** Set of block IDs that need re-execution (start block + all downstream) */
dirtySet: Set<string>
}
/**
* Computes all blocks that need re-execution when running from a specific block.
* Uses BFS to find all downstream blocks reachable via outgoing edges.
*
* @param dag - The workflow DAG
* @param startBlockId - The block to start execution from
* @returns Set of block IDs that are "dirty" and need re-execution
*/
export function computeDirtySet(dag: DAG, startBlockId: string): Set<string> {
const dirty = new Set<string>([startBlockId])
const queue = [startBlockId]
while (queue.length > 0) {
const nodeId = queue.shift()!
const node = dag.nodes.get(nodeId)
if (!node) continue
for (const [, edge] of node.outgoingEdges) {
if (!dirty.has(edge.target)) {
dirty.add(edge.target)
queue.push(edge.target)
}
}
}
logger.debug('Computed dirty set', {
startBlockId,
dirtySetSize: dirty.size,
dirtyBlocks: Array.from(dirty),
})
return dirty
}
/**
* Validates that a block can be used as a run-from-block starting point.
*
* Validation rules:
* - Block must exist in the DAG
* - Block cannot be inside a loop
* - Block cannot be inside a parallel
* - Block cannot be a sentinel node
* - Block must have been executed in the source run
*
* @param blockId - The block ID to validate
* @param dag - The workflow DAG
* @param executedBlocks - Set of blocks that were executed in the source run
* @returns Validation result with error message if invalid
*/
export function validateRunFromBlock(
blockId: string,
dag: DAG,
executedBlocks: Set<string>
): RunFromBlockValidation {
const node = dag.nodes.get(blockId)
if (!node) {
return { valid: false, error: `Block not found in workflow: ${blockId}` }
}
if (node.metadata.isLoopNode) {
return {
valid: false,
error: `Cannot run from block inside loop: ${node.metadata.loopId}`,
}
}
if (node.metadata.isParallelBranch) {
return {
valid: false,
error: `Cannot run from block inside parallel: ${node.metadata.parallelId}`,
}
}
if (node.metadata.isSentinel) {
return { valid: false, error: 'Cannot run from sentinel node' }
}
if (!executedBlocks.has(blockId)) {
return {
valid: false,
error: `Block was not executed in source run: ${blockId}`,
}
}
return { valid: true }
}

View File

@@ -0,0 +1,239 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCodeUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CodeUndoRedo')
interface UseCodeUndoRedoOptions {
blockId: string
subBlockId: string
value: string
enabled?: boolean
isReadOnly?: boolean
isStreaming?: boolean
debounceMs?: number
}
export function useCodeUndoRedo({
blockId,
subBlockId,
value,
enabled = true,
isReadOnly = false,
isStreaming = false,
debounceMs = 500,
}: UseCodeUndoRedoOptions) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const { isShowingDiff, hasActiveDiff } = useWorkflowDiffStore(
useShallow((state) => ({
isShowingDiff: state.isShowingDiff,
hasActiveDiff: state.hasActiveDiff,
}))
)
const isBaselineView = hasActiveDiff && !isShowingDiff
const isEnabled = useMemo(
() => Boolean(enabled && activeWorkflowId && !isReadOnly && !isStreaming && !isBaselineView),
[enabled, activeWorkflowId, isReadOnly, isStreaming, isBaselineView]
)
const isReplaceEnabled = useMemo(
() => Boolean(enabled && activeWorkflowId && !isReadOnly && !isBaselineView),
[enabled, activeWorkflowId, isReadOnly, isBaselineView]
)
const lastCommittedValueRef = useRef<string>(value ?? '')
const pendingBeforeRef = useRef<string | null>(null)
const pendingAfterRef = useRef<string | null>(null)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isApplyingRef = useRef(false)
const clearTimer = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
const resetPending = useCallback(() => {
pendingBeforeRef.current = null
pendingAfterRef.current = null
}, [])
const commitPending = useCallback(() => {
if (!isEnabled || !activeWorkflowId) {
clearTimer()
resetPending()
return
}
const before = pendingBeforeRef.current
const after = pendingAfterRef.current
if (before === null || after === null) return
if (before === after) {
lastCommittedValueRef.current = after
clearTimer()
resetPending()
return
}
useCodeUndoRedoStore.getState().push({
id: crypto.randomUUID(),
createdAt: Date.now(),
workflowId: activeWorkflowId,
blockId,
subBlockId,
before,
after,
})
lastCommittedValueRef.current = after
clearTimer()
resetPending()
}, [activeWorkflowId, blockId, clearTimer, isEnabled, resetPending, subBlockId])
const recordChange = useCallback(
(nextValue: string) => {
if (!isEnabled || isApplyingRef.current) return
if (pendingBeforeRef.current === null) {
pendingBeforeRef.current = lastCommittedValueRef.current ?? ''
}
pendingAfterRef.current = nextValue
clearTimer()
timeoutRef.current = setTimeout(commitPending, debounceMs)
},
[clearTimer, commitPending, debounceMs, isEnabled]
)
const recordReplace = useCallback(
(nextValue: string) => {
if (!isReplaceEnabled || isApplyingRef.current || !activeWorkflowId) return
if (pendingBeforeRef.current !== null) {
commitPending()
}
const before = lastCommittedValueRef.current ?? ''
if (before === nextValue) {
lastCommittedValueRef.current = nextValue
resetPending()
return
}
useCodeUndoRedoStore.getState().push({
id: crypto.randomUUID(),
createdAt: Date.now(),
workflowId: activeWorkflowId,
blockId,
subBlockId,
before,
after: nextValue,
})
lastCommittedValueRef.current = nextValue
clearTimer()
resetPending()
},
[
activeWorkflowId,
blockId,
clearTimer,
commitPending,
isReplaceEnabled,
resetPending,
subBlockId,
]
)
const flushPending = useCallback(() => {
if (pendingBeforeRef.current === null) return
clearTimer()
commitPending()
}, [clearTimer, commitPending])
const startSession = useCallback(
(currentValue: string) => {
clearTimer()
resetPending()
lastCommittedValueRef.current = currentValue ?? ''
},
[clearTimer, resetPending]
)
const applyValue = useCallback(
(nextValue: string) => {
if (!isEnabled) return
isApplyingRef.current = true
try {
collaborativeSetSubblockValue(blockId, subBlockId, nextValue)
} finally {
isApplyingRef.current = false
}
lastCommittedValueRef.current = nextValue
clearTimer()
resetPending()
},
[blockId, clearTimer, collaborativeSetSubblockValue, isEnabled, resetPending, subBlockId]
)
const undo = useCallback(() => {
if (!activeWorkflowId || !isEnabled) return
if (pendingBeforeRef.current !== null) {
flushPending()
}
const entry = useCodeUndoRedoStore.getState().undo(activeWorkflowId, blockId, subBlockId)
if (!entry) return
logger.debug('Undo code edit', { blockId, subBlockId })
applyValue(entry.before)
}, [activeWorkflowId, applyValue, blockId, flushPending, isEnabled, subBlockId])
const redo = useCallback(() => {
if (!activeWorkflowId || !isEnabled) return
if (pendingBeforeRef.current !== null) {
flushPending()
}
const entry = useCodeUndoRedoStore.getState().redo(activeWorkflowId, blockId, subBlockId)
if (!entry) return
logger.debug('Redo code edit', { blockId, subBlockId })
applyValue(entry.after)
}, [activeWorkflowId, applyValue, blockId, flushPending, isEnabled, subBlockId])
useEffect(() => {
if (isApplyingRef.current || isStreaming) return
const nextValue = value ?? ''
if (pendingBeforeRef.current !== null) {
if (pendingAfterRef.current !== nextValue) {
clearTimer()
resetPending()
lastCommittedValueRef.current = nextValue
}
return
}
lastCommittedValueRef.current = nextValue
}, [clearTimer, isStreaming, resetPending, value])
useEffect(() => {
return () => {
flushPending()
}
}, [flushPending])
return {
recordChange,
recordReplace,
flushPending,
startSession,
undo,
redo,
}
}

View File

@@ -20,7 +20,7 @@ import {
import { useNotificationStore } from '@/stores/notifications'
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
import { usePanelEditorStore, useVariablesStore } from '@/stores/panel'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -449,6 +449,10 @@ export function useCollaborativeWorkflow() {
try {
// The setValue function automatically uses the active workflow ID
useSubBlockStore.getState().setValue(blockId, subblockId, value)
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
if (activeWorkflowId && blockType === 'function' && subblockId === 'code') {
useCodeUndoRedoStore.getState().clear(activeWorkflowId, blockId, subblockId)
}
} catch (error) {
logger.error('Error applying remote subblock update:', error)
} finally {

View File

@@ -1,85 +1,10 @@
import { useCallback, useRef } from 'react'
import { createLogger } from '@sim/logger'
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
const logger = createLogger('useExecutionStream')
/**
* Processes SSE events from a response body and invokes appropriate callbacks.
*/
async function processSSEStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
callbacks: ExecutionStreamCallbacks,
logPrefix: string
): Promise<void> {
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue
const data = line.substring(6).trim()
if (data === '[DONE]') {
logger.info(`${logPrefix} stream completed`)
continue
}
try {
const event = JSON.parse(data) as ExecutionEvent
switch (event.type) {
case 'execution:started':
callbacks.onExecutionStarted?.(event.data)
break
case 'execution:completed':
callbacks.onExecutionCompleted?.(event.data)
break
case 'execution:error':
callbacks.onExecutionError?.(event.data)
break
case 'execution:cancelled':
callbacks.onExecutionCancelled?.(event.data)
break
case 'block:started':
callbacks.onBlockStarted?.(event.data)
break
case 'block:completed':
callbacks.onBlockCompleted?.(event.data)
break
case 'block:error':
callbacks.onBlockError?.(event.data)
break
case 'stream:chunk':
callbacks.onStreamChunk?.(event.data)
break
case 'stream:done':
callbacks.onStreamDone?.(event.data)
break
default:
logger.warn('Unknown event type:', (event as any).type)
}
} catch (error) {
logger.error('Failed to parse SSE event:', error, { data })
}
}
}
} finally {
reader.releaseLock()
}
}
export interface ExecutionStreamCallbacks {
onExecutionStarted?: (data: { startTime: string }) => void
onExecutionCompleted?: (data: {
@@ -146,13 +71,6 @@ export interface ExecuteStreamOptions {
callbacks?: ExecutionStreamCallbacks
}
export interface ExecuteFromBlockOptions {
workflowId: string
startBlockId: string
sourceSnapshot: SerializableExecutionState
callbacks?: ExecutionStreamCallbacks
}
/**
* Hook for executing workflows via server-side SSE streaming
*/
@@ -201,7 +119,91 @@ export function useExecutionStream() {
}
const reader = response.body.getReader()
await processSSEStream(reader, callbacks, 'Execution')
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) {
continue
}
const data = line.substring(6).trim()
if (data === '[DONE]') {
logger.info('Stream completed')
continue
}
try {
const event = JSON.parse(data) as ExecutionEvent
logger.info('📡 SSE Event received:', {
type: event.type,
executionId: event.executionId,
data: event.data,
})
switch (event.type) {
case 'execution:started':
logger.info('🚀 Execution started')
callbacks.onExecutionStarted?.(event.data)
break
case 'execution:completed':
logger.info('✅ Execution completed')
callbacks.onExecutionCompleted?.(event.data)
break
case 'execution:error':
logger.error('❌ Execution error')
callbacks.onExecutionError?.(event.data)
break
case 'execution:cancelled':
logger.warn('🛑 Execution cancelled')
callbacks.onExecutionCancelled?.(event.data)
break
case 'block:started':
logger.info('🔷 Block started:', event.data.blockId)
callbacks.onBlockStarted?.(event.data)
break
case 'block:completed':
logger.info('✓ Block completed:', event.data.blockId)
callbacks.onBlockCompleted?.(event.data)
break
case 'block:error':
logger.error('✗ Block error:', event.data.blockId)
callbacks.onBlockError?.(event.data)
break
case 'stream:chunk':
callbacks.onStreamChunk?.(event.data)
break
case 'stream:done':
logger.info('Stream done:', event.data.blockId)
callbacks.onStreamDone?.(event.data)
break
default:
logger.warn('Unknown event type:', (event as any).type)
}
} catch (error) {
logger.error('Failed to parse SSE event:', error, { data })
}
}
}
} finally {
reader.releaseLock()
}
} catch (error: any) {
if (error.name === 'AbortError') {
logger.info('Execution stream cancelled')
@@ -220,65 +222,6 @@ export function useExecutionStream() {
}
}, [])
const executeFromBlock = useCallback(async (options: ExecuteFromBlockOptions) => {
const { workflowId, startBlockId, sourceSnapshot, callbacks = {} } = options
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
currentExecutionRef.current = null
try {
const response = await fetch(`/api/workflows/${workflowId}/execute-from-block`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ startBlockId, sourceSnapshot }),
signal: abortController.signal,
})
if (!response.ok) {
const errorResponse = await response.json()
const error = new Error(errorResponse.error || 'Failed to start execution')
if (errorResponse && typeof errorResponse === 'object') {
Object.assign(error, { executionResult: errorResponse })
}
throw error
}
if (!response.body) {
throw new Error('No response body')
}
const executionId = response.headers.get('X-Execution-Id')
if (executionId) {
currentExecutionRef.current = { workflowId, executionId }
}
const reader = response.body.getReader()
await processSSEStream(reader, callbacks, 'Run-from-block')
} catch (error: any) {
if (error.name === 'AbortError') {
logger.info('Run-from-block execution cancelled')
callbacks.onExecutionCancelled?.({ duration: 0 })
} else {
logger.error('Run-from-block execution error:', error)
callbacks.onExecutionError?.({
error: error.message || 'Unknown error',
duration: 0,
})
}
throw error
} finally {
abortControllerRef.current = null
currentExecutionRef.current = null
}
}, [])
const cancel = useCallback(() => {
const execution = currentExecutionRef.current
if (execution) {
@@ -296,7 +239,6 @@ export function useExecutionStream() {
return {
execute,
executeFromBlock,
cancel,
}
}

View File

@@ -21,7 +21,6 @@ export const COPILOT_MODEL_IDS = [
'claude-4.5-opus',
'claude-4.1-opus',
'gemini-3-pro',
'auto',
] as const
export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number]

View File

@@ -37,6 +37,13 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
if (!args.workspaceId) {
return {
success: false,
message: 'Workspace ID is required for creating a knowledge base',
}
}
const requestId = crypto.randomUUID().slice(0, 8)
const newKnowledgeBase = await createKnowledgeBase(
{

View File

@@ -79,7 +79,7 @@ export const KnowledgeBaseArgsSchema = z.object({
name: z.string().optional(),
/** Description of the knowledge base (optional for create) */
description: z.string().optional(),
/** Workspace ID to associate with (optional for create/list) */
/** Workspace ID to associate with (required for create, optional for list) */
workspaceId: z.string().optional(),
/** Knowledge base ID (required for get, query) */
knowledgeBaseId: z.string().optional(),

View File

@@ -86,18 +86,16 @@ export async function createKnowledgeBase(
const kbId = randomUUID()
const now = new Date()
if (data.workspaceId) {
const hasPermission = await getUserEntityPermissions(data.userId, 'workspace', data.workspaceId)
if (hasPermission === null) {
throw new Error('User does not have permission to create knowledge bases in this workspace')
}
const hasPermission = await getUserEntityPermissions(data.userId, 'workspace', data.workspaceId)
if (hasPermission === null) {
throw new Error('User does not have permission to create knowledge bases in this workspace')
}
const newKnowledgeBase = {
id: kbId,
name: data.name,
description: data.description ?? null,
workspaceId: data.workspaceId ?? null,
workspaceId: data.workspaceId,
userId: data.userId,
tokenCount: 0,
embeddingModel: data.embeddingModel,
@@ -122,7 +120,7 @@ export async function createKnowledgeBase(
chunkingConfig: data.chunkingConfig,
createdAt: now,
updatedAt: now,
workspaceId: data.workspaceId ?? null,
workspaceId: data.workspaceId,
docCount: 0,
}
}

View File

@@ -32,7 +32,7 @@ export interface KnowledgeBaseWithCounts {
export interface CreateKnowledgeBaseData {
name: string
description?: string
workspaceId?: string
workspaceId: string
embeddingModel: 'text-embedding-3-small'
embeddingDimension: 1536
chunkingConfig: ChunkingConfig

View File

@@ -557,7 +557,8 @@ describe('hasWorkflowChanged', () => {
})
describe('InputFormat SubBlock Special Handling', () => {
it.concurrent('should ignore value and collapsed fields in inputFormat', () => {
it.concurrent('should ignore collapsed field but detect value changes in inputFormat', () => {
// Only collapsed changes - should NOT detect as change
const state1 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
@@ -578,8 +579,8 @@ describe('hasWorkflowChanged', () => {
subBlocks: {
inputFormat: {
value: [
{ id: 'input1', name: 'Name', value: 'Jane', collapsed: false },
{ id: 'input2', name: 'Age', value: 30, collapsed: true },
{ id: 'input1', name: 'Name', value: 'John', collapsed: false },
{ id: 'input2', name: 'Age', value: 25, collapsed: true },
],
},
},
@@ -589,6 +590,32 @@ describe('hasWorkflowChanged', () => {
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
it.concurrent('should detect value changes in inputFormat', () => {
const state1 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'input1', name: 'Name', value: 'John' }],
},
},
}),
},
})
const state2 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'input1', name: 'Name', value: 'Jane' }],
},
},
}),
},
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should detect actual inputFormat changes', () => {
const state1 = createWorkflowState({
blocks: {
@@ -1712,15 +1739,15 @@ describe('hasWorkflowChanged', () => {
})
describe('Input Format Field Scenarios', () => {
it.concurrent('should not detect change when inputFormat value is typed and cleared', () => {
// The "value" field in inputFormat is UI-only and should be ignored
it.concurrent('should not detect change when only inputFormat collapsed changes', () => {
// The "collapsed" field in inputFormat is UI-only and should be ignored
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [
{ id: 'field1', name: 'Name', type: 'string', value: '', collapsed: false },
{ id: 'field1', name: 'Name', type: 'string', value: 'test', collapsed: false },
],
},
},
@@ -1738,7 +1765,7 @@ describe('hasWorkflowChanged', () => {
id: 'field1',
name: 'Name',
type: 'string',
value: 'typed then cleared',
value: 'test',
collapsed: true,
},
],
@@ -1748,10 +1775,40 @@ describe('hasWorkflowChanged', () => {
},
})
// value and collapsed are UI-only fields - should NOT detect as change
// collapsed is UI-only field - should NOT detect as change
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should detect change when inputFormat value changes', () => {
// The "value" field in inputFormat is meaningful and should trigger change detection
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'field1', name: 'Name', type: 'string', value: '' }],
},
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
subBlocks: {
inputFormat: {
value: [{ id: 'field1', name: 'Name', type: 'string', value: 'new value' }],
},
},
}),
},
})
// value changes should be detected
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
})
it.concurrent('should detect change when inputFormat field name changes', () => {
const deployedState = createWorkflowState({
blocks: {

View File

@@ -370,7 +370,7 @@ describe('Workflow Normalization Utilities', () => {
expect(sanitizeInputFormat({} as any)).toEqual([])
})
it.concurrent('should remove value and collapsed fields', () => {
it.concurrent('should remove collapsed field but keep value', () => {
const inputFormat = [
{ id: 'input1', name: 'Name', value: 'John', collapsed: true },
{ id: 'input2', name: 'Age', value: 25, collapsed: false },
@@ -379,13 +379,13 @@ describe('Workflow Normalization Utilities', () => {
const result = sanitizeInputFormat(inputFormat)
expect(result).toEqual([
{ id: 'input1', name: 'Name' },
{ id: 'input2', name: 'Age' },
{ id: 'input1', name: 'Name', value: 'John' },
{ id: 'input2', name: 'Age', value: 25 },
{ id: 'input3', name: 'Email' },
])
})
it.concurrent('should preserve all other fields', () => {
it.concurrent('should preserve all other fields including value', () => {
const inputFormat = [
{
id: 'input1',
@@ -402,6 +402,7 @@ describe('Workflow Normalization Utilities', () => {
expect(result[0]).toEqual({
id: 'input1',
name: 'Complex Input',
value: 'test-value',
type: 'string',
required: true,
validation: { min: 0, max: 100 },

View File

@@ -156,10 +156,10 @@ export function normalizeVariables(variables: unknown): Record<string, Variable>
}
/** Input format item with optional UI-only fields */
type InputFormatItem = Record<string, unknown> & { value?: unknown; collapsed?: boolean }
type InputFormatItem = Record<string, unknown> & { collapsed?: boolean }
/**
* Sanitizes inputFormat array by removing UI-only fields like value and collapsed
* Sanitizes inputFormat array by removing UI-only fields like collapsed
* @param inputFormat - Array of input format configurations
* @returns Sanitized input format array
*/
@@ -167,7 +167,7 @@ export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record<
if (!Array.isArray(inputFormat)) return []
return inputFormat.map((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const { value, collapsed, ...rest } = item as InputFormatItem
const { collapsed, ...rest } = item as InputFormatItem
return rest
}
return item as Record<string, unknown>

View File

@@ -9,6 +9,7 @@ import {
generateToolUseId,
} from '@/providers/anthropic/utils'
import {
getMaxOutputTokensForModel,
getProviderDefaultModel,
getProviderModels,
supportsNativeStructuredOutputs,
@@ -178,7 +179,9 @@ export const anthropicProvider: ProviderConfig = {
model: request.model,
messages,
system: systemPrompt,
max_tokens: Number.parseInt(String(request.maxTokens)) || 1024,
max_tokens:
Number.parseInt(String(request.maxTokens)) ||
getMaxOutputTokensForModel(request.model, request.stream ?? false),
temperature: Number.parseFloat(String(request.temperature ?? 0.7)),
}

View File

@@ -20,7 +20,11 @@ import {
generateToolUseId,
getBedrockInferenceProfileId,
} from '@/providers/bedrock/utils'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import {
getMaxOutputTokensForModel,
getProviderDefaultModel,
getProviderModels,
} from '@/providers/models'
import type {
ProviderConfig,
ProviderRequest,
@@ -259,7 +263,9 @@ export const bedrockProvider: ProviderConfig = {
const inferenceConfig = {
temperature: Number.parseFloat(String(request.temperature ?? 0.7)),
maxTokens: Number.parseInt(String(request.maxTokens)) || 4096,
maxTokens:
Number.parseInt(String(request.maxTokens)) ||
getMaxOutputTokensForModel(request.model, request.stream ?? false),
}
const shouldStreamToolCalls = request.streamToolCalls ?? false

View File

@@ -34,6 +34,12 @@ export interface ModelCapabilities {
toolUsageControl?: boolean
computerUse?: boolean
nativeStructuredOutputs?: boolean
maxOutputTokens?: {
/** Maximum tokens for streaming requests */
max: number
/** Safe default for non-streaming requests (to avoid timeout issues) */
default: number
}
reasoningEffort?: {
values: string[]
}
@@ -613,6 +619,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -627,6 +634,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -640,6 +648,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -654,6 +663,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -668,6 +678,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -681,6 +692,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -695,6 +707,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
computerUse: true,
maxOutputTokens: { max: 8192, default: 8192 },
},
contextWindow: 200000,
},
@@ -709,6 +722,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
computerUse: true,
maxOutputTokens: { max: 8192, default: 8192 },
},
contextWindow: 200000,
},
@@ -1655,6 +1669,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -1668,6 +1683,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -1681,6 +1697,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -1694,6 +1711,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: { max: 64000, default: 8192 },
},
contextWindow: 200000,
},
@@ -2333,3 +2351,31 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null {
const capability = getThinkingCapability(modelId)
return capability?.levels ?? null
}
/**
* Get the max output tokens for a specific model
* Returns the model's max capacity for streaming requests,
* or the model's safe default for non-streaming requests to avoid timeout issues.
*
* @param modelId - The model ID
* @param streaming - Whether the request is streaming (default: false)
*/
export function getMaxOutputTokensForModel(modelId: string, streaming = false): number {
const normalizedModelId = modelId.toLowerCase()
const STANDARD_MAX_OUTPUT_TOKENS = 4096
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
for (const model of provider.models) {
const baseModelId = model.id.toLowerCase()
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
const outputTokens = model.capabilities.maxOutputTokens
if (outputTokens) {
return streaming ? outputTokens.max : outputTokens.default
}
return STANDARD_MAX_OUTPUT_TOKENS
}
}
}
return STANDARD_MAX_OUTPUT_TOKENS
}

View File

@@ -8,6 +8,7 @@ import {
getComputerUseModels,
getEmbeddingModelPricing,
getHostedModels as getHostedModelsFromDefinitions,
getMaxOutputTokensForModel as getMaxOutputTokensForModelFromDefinitions,
getMaxTemperature as getMaxTempFromDefinitions,
getModelPricing as getModelPricingFromDefinitions,
getModelsWithReasoningEffort,
@@ -992,6 +993,18 @@ export function getThinkingLevelsForModel(model: string): string[] | null {
return getThinkingLevelsForModelFromDefinitions(model)
}
/**
* Get max output tokens for a specific model
* Returns the model's maxOutputTokens capability for streaming requests,
* or a conservative default (8192) for non-streaming requests to avoid timeout issues.
*
* @param model - The model ID
* @param streaming - Whether the request is streaming (default: false)
*/
export function getMaxOutputTokensForModel(model: string, streaming = false): number {
return getMaxOutputTokensForModelFromDefinitions(model, streaming)
}
/**
* Prepare tool execution parameters, separating tool parameters from system parameters
*/

View File

@@ -35,23 +35,4 @@ export const useExecutionStore = create<ExecutionState & ExecutionActions>()((se
},
clearRunPath: () => set({ lastRunPath: new Map(), lastRunEdges: new Map() }),
reset: () => set(initialState),
setLastExecutionSnapshot: (workflowId, snapshot) => {
const { lastExecutionSnapshots } = get()
const newSnapshots = new Map(lastExecutionSnapshots)
newSnapshots.set(workflowId, snapshot)
set({ lastExecutionSnapshots: newSnapshots })
},
getLastExecutionSnapshot: (workflowId) => {
const { lastExecutionSnapshots } = get()
return lastExecutionSnapshots.get(workflowId)
},
clearLastExecutionSnapshot: (workflowId) => {
const { lastExecutionSnapshots } = get()
const newSnapshots = new Map(lastExecutionSnapshots)
newSnapshots.delete(workflowId)
set({ lastExecutionSnapshots: newSnapshots })
},
}))

View File

@@ -1,5 +1,4 @@
import type { Executor } from '@/executor'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionContext } from '@/executor/types'
/**
@@ -29,11 +28,6 @@ export interface ExecutionState {
* Cleared when a new run starts. Used to show run path indicators on edges.
*/
lastRunEdges: Map<string, EdgeRunStatus>
/**
* Stores the last successful execution snapshot per workflow.
* Used for run-from-block functionality.
*/
lastExecutionSnapshots: Map<string, SerializableExecutionState>
}
export interface ExecutionActions {
@@ -47,18 +41,6 @@ export interface ExecutionActions {
setEdgeRunStatus: (edgeId: string, status: EdgeRunStatus) => void
clearRunPath: () => void
reset: () => void
/**
* Store the execution snapshot for a workflow after successful execution.
*/
setLastExecutionSnapshot: (workflowId: string, snapshot: SerializableExecutionState) => void
/**
* Get the last execution snapshot for a workflow.
*/
getLastExecutionSnapshot: (workflowId: string) => SerializableExecutionState | undefined
/**
* Clear the execution snapshot for a workflow.
*/
clearLastExecutionSnapshot: (workflowId: string) => void
}
export const initialState: ExecutionState = {
@@ -70,5 +52,4 @@ export const initialState: ExecutionState = {
debugContext: null,
lastRunPath: new Map(),
lastRunEdges: new Map(),
lastExecutionSnapshots: new Map(),
}

View File

@@ -0,0 +1,36 @@
import { createLogger } from '@sim/logger'
import { del, get, set } from 'idb-keyval'
import type { StateStorage } from 'zustand/middleware'
const logger = createLogger('CodeUndoRedoStorage')
export const codeUndoRedoStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (typeof window === 'undefined') return null
try {
const value = await get<string>(name)
return value ?? null
} catch (error) {
logger.warn('IndexedDB read failed', { name, error })
return null
}
},
setItem: async (name: string, value: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await set(name, value)
} catch (error) {
logger.warn('IndexedDB write failed', { name, error })
}
},
removeItem: async (name: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await del(name)
} catch (error) {
logger.warn('IndexedDB delete failed', { name, error })
}
},
}

View File

@@ -0,0 +1,151 @@
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { codeUndoRedoStorage } from '@/stores/undo-redo/code-storage'
interface CodeUndoRedoEntry {
id: string
createdAt: number
workflowId: string
blockId: string
subBlockId: string
before: string
after: string
}
interface CodeUndoRedoStack {
undo: CodeUndoRedoEntry[]
redo: CodeUndoRedoEntry[]
lastUpdated?: number
}
interface CodeUndoRedoState {
stacks: Record<string, CodeUndoRedoStack>
capacity: number
push: (entry: CodeUndoRedoEntry) => void
undo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
redo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
clear: (workflowId: string, blockId: string, subBlockId: string) => void
}
const DEFAULT_CAPACITY = 500
const MAX_STACKS = 50
function getStackKey(workflowId: string, blockId: string, subBlockId: string): string {
return `${workflowId}:${blockId}:${subBlockId}`
}
const initialState = {
stacks: {} as Record<string, CodeUndoRedoStack>,
capacity: DEFAULT_CAPACITY,
}
export const useCodeUndoRedoStore = create<CodeUndoRedoState>()(
devtools(
persist(
(set, get) => ({
...initialState,
push: (entry) => {
if (entry.before === entry.after) return
const state = get()
const key = getStackKey(entry.workflowId, entry.blockId, entry.subBlockId)
const currentStacks = { ...state.stacks }
const stackKeys = Object.keys(currentStacks)
if (stackKeys.length >= MAX_STACKS && !currentStacks[key]) {
let oldestKey: string | null = null
let oldestTime = Number.POSITIVE_INFINITY
for (const stackKey of stackKeys) {
const t = currentStacks[stackKey].lastUpdated ?? 0
if (t < oldestTime) {
oldestTime = t
oldestKey = stackKey
}
}
if (oldestKey) {
delete currentStacks[oldestKey]
}
}
const stack = currentStacks[key] || { undo: [], redo: [] }
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
currentStacks[key] = {
undo: newUndo,
redo: [],
lastUpdated: Date.now(),
}
set({ stacks: currentStacks })
},
undo: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.undo.length === 0) return null
const entry = stack.undo[stack.undo.length - 1]
const newUndo = stack.undo.slice(0, -1)
const newRedo = [...stack.redo, entry]
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo,
redo: newRedo.slice(-state.capacity),
lastUpdated: Date.now(),
},
},
})
return entry
},
redo: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.redo.length === 0) return null
const entry = stack.redo[stack.redo.length - 1]
const newRedo = stack.redo.slice(0, -1)
const newUndo = [...stack.undo, entry]
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo.slice(-state.capacity),
redo: newRedo,
lastUpdated: Date.now(),
},
},
})
return entry
},
clear: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const { [key]: _, ...rest } = state.stacks
set({ stacks: rest })
},
}),
{
name: 'code-undo-redo-store',
storage: createJSONStorage(() => codeUndoRedoStorage),
partialize: (state) => ({
stacks: state.stacks,
capacity: state.capacity,
}),
}
),
{ name: 'code-undo-redo-store' }
)
)

View File

@@ -1,3 +1,4 @@
export { useCodeUndoRedoStore } from './code-store'
export { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from './store'
export * from './types'
export * from './utils'

View File

@@ -5,7 +5,7 @@ import type { ToolConfig, ToolResponse } from '@/tools/types'
const logger = createLogger('BrowserUseTool')
const POLL_INTERVAL_MS = 5000
const MAX_POLL_TIME_MS = 180000
const MAX_POLL_TIME_MS = 600000 // 10 minutes
const MAX_CONSECUTIVE_ERRORS = 3
async function createSessionWithProfile(

View File

@@ -291,7 +291,7 @@
},
"packages/ts-sdk": {
"name": "simstudio-ts-sdk",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"node-fetch": "^3.3.2",
},