Compare commits

..

7 Commits

Author SHA1 Message Date
Vikhyath Mondreti
a15dd4c09e remove passed in workspace id ref 2026-01-26 16:17:29 -08:00
Siddharth Ganesan
80eb2a8aa1 Fix 2026-01-26 12:25:01 -08:00
Siddharth Ganesan
315d9ee3f9 Lint 2026-01-26 10:21:01 -08:00
Siddharth Ganesan
62b06d00de Fix comments 2026-01-25 14:33:27 -08:00
Siddharth Ganesan
2a630859fb Fix validation 2026-01-25 14:31:12 -08:00
Siddharth Ganesan
3533bd009d Fix greptile 2026-01-25 13:27:51 -08:00
Siddharth Ganesan
43402fde1c Fix 2026-01-25 13:15:07 -08:00
143 changed files with 2003 additions and 4748 deletions

View File

@@ -10,9 +10,6 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
test-build:
name: Test and Build
@@ -281,30 +278,3 @@ jobs:
if: needs.check-docs-changes.outputs.docs_changed == 'true'
uses: ./.github/workflows/docs-embeddings.yml
secrets: inherit
# Create GitHub Release (only for version commits on main, after all builds complete)
create-release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [create-ghcr-manifests, detect-version]
if: needs.detect-version.outputs.is_release == 'true'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Create release
env:
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }}

View File

@@ -4,9 +4,6 @@ on:
workflow_call:
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
jobs:
process-docs-embeddings:
name: Process Documentation Embeddings

View File

@@ -4,9 +4,6 @@ on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
migrate:
name: Apply Database Migrations

View File

@@ -6,9 +6,6 @@ on:
paths:
- 'packages/cli/**'
permissions:
contents: read
jobs:
publish-npm:
runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -6,9 +6,6 @@ on:
paths:
- 'packages/python-sdk/**'
permissions:
contents: write
jobs:
publish-pypi:
runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -6,9 +6,6 @@ on:
paths:
- 'packages/ts-sdk/**'
permissions:
contents: write
jobs:
publish-npm:
runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -4,9 +4,6 @@ on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
test-build:
name: Test and Build

View File

@@ -185,6 +185,11 @@ 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,7 +102,16 @@ export default async function Layout({ children, params }: LayoutProps) {
<DocsLayout
tree={source.pageTree[lang]}
nav={{
title: <SimLogoFull className='h-7 w-auto' />,
title: (
<Image
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
priority
/>
),
}}
sidebar={{
defaultOpenLevel: 0,

View File

@@ -33,41 +33,15 @@ 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)
@@ -78,39 +52,84 @@ export async function GET(request: NextRequest) {
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '56px 64px',
background: '#121212', // Dark mode background matching docs (hsla 0, 0%, 7%)
background: '#0c0c0c',
position: 'relative',
fontFamily: 'Geist',
}}
>
{/* Title at top */}
<span
{/* Base gradient layer - subtle purple tint across the entire image */}
<div
style={{
fontSize: getTitleFontSize(title),
fontWeight: 500,
color: '#fafafa', // Light text matching docs
lineHeight: 1.2,
letterSpacing: '-0.02em',
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',
}}
>
{title}
</span>
/>
{/* Footer: icon left, domain right */}
{/* 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',
alignItems: 'center',
width: '100%',
}}
>
<SimLogoFull />
{/* Logo */}
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
{/* Title */}
<span
style={{
fontSize: getTitleFontSize(title),
fontWeight: 600,
color: '#ffffff',
lineHeight: 1.1,
letterSpacing: '-0.02em',
}}
>
{title}
</span>
{/* Footer */}
<span
style={{
fontSize: 20,
fontWeight: 400,
fontWeight: 500,
color: '#71717a',
}}
>

View File

@@ -9,20 +9,11 @@ body {
}
@theme {
--color-fd-primary: #33c482; /* Green from Sim logo */
--color-fd-primary: #802fff; /* Purple from control-bar component */
--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,
@@ -43,7 +34,7 @@ body {
:root {
--fd-border: transparent !important;
--fd-border-sidebar: transparent !important;
--fd-nav-height: 65px; /* Custom navbar height (h-16 = 64px + 1px border) */
--fd-nav-height: 64px; /* Custom navbar height (h-16 = 4rem = 64px) */
/* Content container width used to center main content */
--spacing-fd-container: 1400px;
/* Edge gutter = leftover space on each side of centered container */
@@ -145,11 +136,11 @@ aside#nd-sidebar {
/* On mobile, let fumadocs handle the layout natively */
@media (min-width: 1024px) {
:root {
--fd-banner-height: 65px !important; /* 64px navbar + 1px border */
--fd-banner-height: 64px !important;
}
#nd-docs-layout {
--fd-docs-height: calc(100dvh - 65px) !important; /* 64px navbar + 1px border */
--fd-docs-height: calc(100dvh - 64px) !important;
--fd-sidebar-width: 300px !important;
margin-left: var(--sidebar-offset) !important;
margin-right: var(--toc-offset) !important;
@@ -236,19 +227,19 @@ html:not(.dark) #nd-sidebar button:not([aria-label*="ollapse"]):not([aria-label*
letter-spacing: 0.05em !important;
}
/* Override active state */
/* Override active state (NO PURPLE) */
#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 green classes */
/* Override custom sidebar purple classes */
#nd-sidebar
a.bg-emerald-50\/80,
#nd-sidebar a.text-emerald-600,
#nd-sidebar a[class*="bg-emerald"],
#nd-sidebar a[class*="text-emerald"] {
a.bg-purple-50\/80,
#nd-sidebar a.text-purple-600,
#nd-sidebar a[class*="bg-purple"],
#nd-sidebar a[class*="text-purple"] {
background-image: none !important;
}
@@ -259,10 +250,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-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"] {
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"] {
background-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 1) !important;
}
@@ -274,10 +265,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-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"] {
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"] {
background-color: rgba(0, 0, 0, 0.07) !important;
color: rgba(0, 0, 0, 0.9) !important;
}
@@ -295,8 +286,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-emerald-50\/80:hover,
html.dark #nd-sidebar a[class*="bg-emerald"]: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[data-active="true"]:hover,
html.dark #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(255, 255, 255, 0.15) !important;
@@ -304,8 +295,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-emerald-50\/80:hover,
html:not(.dark) #nd-sidebar a[class*="bg-emerald"]: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[data-active="true"]:hover,
html:not(.dark) #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(0, 0, 0, 0.07) !important;
@@ -377,24 +368,16 @@ 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"],
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,
[data-sidebar-header],
[data-sidebar] [data-title],
#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,
@@ -532,15 +515,6 @@ 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-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-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-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-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-emerald-50/80 lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400'
'lg:bg-purple-50/80 lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-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-[#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'
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'
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,7 +27,13 @@ export function Navbar() {
{/* Left cluster: logo */}
<div className='flex items-center'>
<Link href='/' className='flex min-w-[100px] items-center'>
<SimLogoFull className='h-7 w-auto' />
<Image
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
/>
</Link>
</div>

View File

@@ -1,87 +0,0 @@
'use client'
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, enableLightbox = true }: ActionImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const handleClick = () => {
if (enableLightbox) {
setIsLightboxOpen(true)
}
}
return (
<>
<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, 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
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,9 +1,8 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { Check, ChevronRight } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
const languages = {
en: { name: 'English', flag: '🇺🇸' },
@@ -16,7 +15,6 @@ 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()
@@ -73,15 +71,6 @@ 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
@@ -93,14 +82,14 @@ export function LanguageDropdown() {
aria-haspopup='listbox'
aria-expanded={isOpen}
aria-controls='language-menu'
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'
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'
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}}
>
<span>{languages[currentLang as keyof typeof languages]?.name}</span>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', isOpen && 'rotate-180')} />
<ChevronRight className='h-3.5 w-3.5' />
</button>
{isOpen && (
@@ -109,37 +98,29 @@ export function LanguageDropdown() {
<div
id='language-menu'
role='listbox'
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'
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'
>
{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>
)
})}
{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>
))}
</div>
</>
)}

View File

@@ -1,108 +0,0 @@
'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 → MCP-Server**
1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs**
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 → MCP-Server** können Sie:
In der Server-Detailansicht unter **Einstellungen → Bereitgestellte MCPs** 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 **MCP-Server**
2. Gehen Sie zum Abschnitt **Bereitgestellte MCPs**
3. Klicken Sie auf **MCP-Server hinzufügen**
4. Geben Sie die Server-Konfigurationsdetails ein
5. Speichern Sie die Konfiguration

View File

@@ -56,10 +56,6 @@ 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 → MCP Servers**
1. Navigate to **Settings → Deployed MCPs**
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 → MCP Servers**, you can:
From the server detail view in **Settings → Deployed MCPs**, 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 **MCP Servers** section
2. Go to the **Deployed MCPs** section
3. Click **Add MCP Server**
4. Enter the server configuration details
5. Save the configuration

View File

@@ -4,7 +4,6 @@ description: Essential actions for navigating and using the Sim workflow editor
---
import { Callout } from 'fumadocs-ui/components/callout'
import { ActionImage, ActionVideo } from '@/components/ui/action-media'
A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts).
@@ -14,362 +13,124 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
## Workspaces
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Create a workspace</td>
<td>Click workspace dropdown → **New Workspace**</td>
<td><ActionVideo src="quick-reference/create-workspace.mp4" alt="Create workspace" /></td>
</tr>
<tr>
<td>Switch workspaces</td>
<td>Click workspace dropdown → Select workspace</td>
<td><ActionVideo src="quick-reference/switch-workspace.mp4" alt="Switch workspaces" /></td>
</tr>
<tr>
<td>Invite team members</td>
<td>Sidebar → **Invite**</td>
<td><ActionVideo src="quick-reference/invite.mp4" alt="Invite team members" /></td>
</tr>
<tr>
<td>Rename a workspace</td>
<td>Right-click workspace → **Rename**</td>
<td rowSpan={4}><ActionImage src="/static/quick-reference/workspace-context-menu.png" alt="Workspace context menu" /></td>
</tr>
<tr>
<td>Duplicate a workspace</td>
<td>Right-click workspace → **Duplicate**</td>
</tr>
<tr>
<td>Export a workspace</td>
<td>Right-click workspace → **Export**</td>
</tr>
<tr>
<td>Delete a workspace</td>
<td>Right-click workspace → **Delete**</td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Create a workspace | Click workspace dropdown in sidebar → **New Workspace** |
| Rename a workspace | Workspace settings → Edit name |
| Switch workspaces | Click workspace dropdown in sidebar → Select workspace |
| Invite team members | Workspace settings → **Team** → **Invite** |
## Workflows
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Create a workflow</td>
<td>Click **+** button in sidebar</td>
<td><ActionImage src="/static/quick-reference/create-workflow.png" alt="Create workflow" /></td>
</tr>
<tr>
<td>Reorder / move workflows</td>
<td>Drag workflow up/down or onto a folder</td>
<td><ActionVideo src="quick-reference/reordering.mp4" alt="Reorder workflows" /></td>
</tr>
<tr>
<td>Import a workflow</td>
<td>Click import button in sidebar → Select file</td>
<td><ActionImage src="/static/quick-reference/import-workflow.png" alt="Import workflow" /></td>
</tr>
<tr>
<td>Multi-select workflows</td>
<td>`Mod+Click` or `Shift+Click` workflows in sidebar</td>
<td><ActionVideo src="quick-reference/multiselect.mp4" alt="Multi-select workflows" /></td>
</tr>
<tr>
<td>Open in new tab</td>
<td>Right-click workflow → **Open in New Tab**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/workflow-context-menu.png" alt="Workflow context menu" /></td>
</tr>
<tr>
<td>Rename a workflow</td>
<td>Right-click workflow → **Rename**</td>
</tr>
<tr>
<td>Assign workflow color</td>
<td>Right-click workflow → **Change Color**</td>
</tr>
<tr>
<td>Duplicate a workflow</td>
<td>Right-click workflow → **Duplicate**</td>
</tr>
<tr>
<td>Export a workflow</td>
<td>Right-click workflow → **Export**</td>
</tr>
<tr>
<td>Delete a workflow</td>
<td>Right-click workflow → **Delete**</td>
</tr>
<tr>
<td>Rename a folder</td>
<td>Right-click folder → **Rename**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/folder-context-menu.png" alt="Folder context menu" /></td>
</tr>
<tr>
<td>Create workflow in folder</td>
<td>Right-click folder → **Create workflow**</td>
</tr>
<tr>
<td>Create folder in folder</td>
<td>Right-click folder → **Create folder**</td>
</tr>
<tr>
<td>Duplicate a folder</td>
<td>Right-click folder → **Duplicate**</td>
</tr>
<tr>
<td>Export a folder</td>
<td>Right-click folder → **Export**</td>
</tr>
<tr>
<td>Delete a folder</td>
<td>Right-click folder → **Delete**</td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Create a workflow | Click **New Workflow** button or `Mod+Shift+A` |
| Rename a workflow | Double-click workflow name in sidebar, or right-click → **Rename** |
| Duplicate a workflow | Right-click workflow → **Duplicate** |
| Reorder workflows | Drag workflow up/down in the sidebar list |
| Import a workflow | Sidebar menu → **Import** → Select file |
| Create a folder | Right-click in sidebar → **New Folder** |
| Rename a folder | Right-click folder → **Rename** |
| Delete a folder | Right-click folder → **Delete** |
| Collapse/expand folder | Click folder arrow, or double-click folder |
| Move workflow to folder | Drag workflow onto folder in sidebar |
| Delete a workflow | Right-click workflow → **Delete** |
| Export a workflow | Right-click workflow → **Export** |
| Assign workflow color | Right-click workflow → **Change Color** |
| Multi-select workflows | `Mod+Click` or `Shift+Click` workflows in sidebar |
| Open in new tab | Right-click workflow → **Open in New Tab** |
## Blocks
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Add a block</td>
<td>Drag from Toolbar panel, or right-click canvas → **Add Block**</td>
<td><ActionVideo src="quick-reference/add-block.mp4" alt="Add a block" /></td>
</tr>
<tr>
<td>Multi-select blocks</td>
<td>`Mod+Click` additional blocks, or shift-drag to draw selection box</td>
<td><ActionVideo src="quick-reference/multiselect-blocks.mp4" alt="Multi-select blocks" /></td>
</tr>
<tr>
<td>Copy blocks</td>
<td>`Mod+C` with blocks selected</td>
<td rowSpan={2}><ActionVideo src="quick-reference/copy-paste.mp4" alt="Copy and paste blocks" /></td>
</tr>
<tr>
<td>Paste blocks</td>
<td>`Mod+V` to paste copied blocks</td>
</tr>
<tr>
<td>Duplicate blocks</td>
<td>Right-click → **Duplicate**</td>
<td><ActionVideo src="quick-reference/duplicate-block.mp4" alt="Duplicate blocks" /></td>
</tr>
<tr>
<td>Delete blocks</td>
<td>`Delete` or `Backspace` key, or right-click → **Delete**</td>
<td><ActionImage src="/static/quick-reference/delete-block.png" alt="Delete block" /></td>
</tr>
<tr>
<td>Rename a block</td>
<td>Click block name in header, or edit in the Editor panel</td>
<td><ActionVideo src="quick-reference/rename-block.mp4" alt="Rename a block" /></td>
</tr>
<tr>
<td>Enable/Disable a block</td>
<td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr>
<tr>
<td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td>
<td><ActionVideo src="quick-reference/toggle-handles.mp4" alt="Toggle handle orientation" /></td>
</tr>
<tr>
<td>Configure a block</td>
<td>Select block → use Editor panel on right</td>
<td><ActionVideo src="quick-reference/configure-block.mp4" alt="Configure a block" /></td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Add a block | Drag from Toolbar panel, or right-click canvas → **Add Block** |
| Select a block | Click on the block |
| Multi-select blocks | `Mod+Click` additional blocks, or right-drag to draw selection box |
| Move blocks | Drag selected block(s) to new position |
| Copy blocks | `Mod+C` with blocks selected |
| Paste blocks | `Mod+V` to paste copied blocks |
| Duplicate blocks | Right-click → **Duplicate** |
| Delete blocks | `Delete` or `Backspace` key, or right-click → **Delete** |
| Rename a block | Click block name in header, or edit in the Editor panel |
| Enable/Disable a block | Right-click → **Enable/Disable** |
| Toggle handle orientation | Right-click → **Toggle Handles** |
| Toggle trigger mode | Right-click trigger block → **Toggle Trigger Mode** |
| Configure a block | Select block → use Editor panel on right |
## Connections
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Create a connection</td>
<td>Drag from output handle to input handle</td>
<td><ActionVideo src="quick-reference/connect-blocks.mp4" alt="Connect blocks" /></td>
</tr>
<tr>
<td>Delete a connection</td>
<td>Click edge to select `Delete` key</td>
<td><ActionVideo src="quick-reference/delete-connection.mp4" alt="Delete connection" /></td>
</tr>
<tr>
<td>Use output in another block</td>
<td>Drag connection tag into input field</td>
<td><ActionVideo src="quick-reference/connection-tag.mp4" alt="Use connection tag" /></td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Create a connection | Drag from output handle to input handle |
| Delete a connection | Click edge to select → `Delete` key |
| Use output in another block | Drag connection tag into input field |
## Canvas Navigation
| Action | How |
|--------|-----|
| Pan/move canvas | Left-drag on empty space, or scroll/trackpad |
| Zoom in/out | Scroll wheel or pinch gesture |
| Auto-layout | `Shift+L` |
| Draw selection box | Right-drag on empty canvas area |
## Panels & Views
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Search toolbar</td>
<td>`Mod+F`</td>
<td><ActionVideo src="quick-reference/search-toolbar.mp4" alt="Search toolbar" /></td>
</tr>
<tr>
<td>Search everything</td>
<td>`Mod+K`</td>
<td><ActionImage src="/static/quick-reference/search-everything.png" alt="Search everything" /></td>
</tr>
<tr>
<td>Toggle manual mode</td>
<td>Click toggle button to switch between manual and selector</td>
<td><ActionImage src="/static/quick-reference/toggle-manual-mode.png" alt="Toggle manual mode" /></td>
</tr>
<tr>
<td>Collapse/expand sidebar</td>
<td>Click collapse button on sidebar</td>
<td><ActionVideo src="quick-reference/collapse-sidebar.mp4" alt="Collapse sidebar" /></td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Open Copilot tab | Press `C` or click Copilot tab |
| Open Toolbar tab | Press `T` or click Toolbar tab |
| Open Editor tab | Press `E` or click Editor tab |
| Search toolbar | `Mod+F` |
| Toggle advanced mode | Click toggle button on input fields |
| Resize panels | Drag panel edge |
| Collapse/expand sidebar | Click collapse button on sidebar |
## Running & Testing
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Run workflow</td>
<td>Click Run Workflow button or `Mod+Enter`</td>
<td><ActionImage src="/static/quick-reference/run-workflow.png" alt="Run workflow" /></td>
</tr>
<tr>
<td>Stop workflow</td>
<td>Click Stop button or `Mod+Enter` while running</td>
<td><ActionImage src="/static/quick-reference/stop-workflow.png" alt="Stop workflow" /></td>
</tr>
<tr>
<td>Test with chat</td>
<td>Use Chat panel on the right side</td>
<td><ActionImage src="/static/quick-reference/test-chat.png" alt="Test with chat" /></td>
</tr>
<tr>
<td>Select output to view</td>
<td>Click dropdown in Chat panel → Select block output</td>
<td><ActionImage src="/static/quick-reference/output-select.png" alt="Select output to view" /></td>
</tr>
<tr>
<td>Clear chat history</td>
<td>Click clear button in Chat panel</td>
<td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td>
</tr>
<tr>
<td>View execution logs</td>
<td>Open terminal panel at bottom, or `Mod+L`</td>
<td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td>
</tr>
<tr>
<td>Filter logs by block or status</td>
<td>Click block filter in terminal or right-click log entry → **Filter by Block** or **Filter by Status**</td>
<td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td>
</tr>
<tr>
<td>Search logs</td>
<td>Use search field in terminal or right-click log entry → **Search**</td>
<td><ActionImage src="/static/quick-reference/terminal-search.png" alt="Search logs" /></td>
</tr>
<tr>
<td>Copy log entry</td>
<td>Clipboard Icon or Right-click log entry → **Copy**</td>
<td><ActionImage src="/static/quick-reference/copy-log.png" alt="Copy log entry" /></td>
</tr>
<tr>
<td>Clear terminal</td>
<td>Trash icon or `Mod+D`</td>
<td><ActionImage src="/static/quick-reference/clear-terminal.png" alt="Clear terminal" /></td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Run workflow | Click Play button or `Mod+Enter` |
| Stop workflow | Click Stop button or `Mod+Enter` while running |
| Test with chat | Use Chat panel on the right side |
| Select output to view | Click dropdown in Chat panel → Select block output |
| Clear chat history | Click clear button in Chat panel |
| View execution logs | Open terminal panel at bottom, or `Mod+L` |
| Filter logs by block | Click block filter in terminal |
| Filter logs by status | Click status filter in terminal |
| Search logs | Use search field in terminal |
| Copy log entry | Right-click log entry → **Copy** |
| Clear terminal | `Mod+D` |
## Deployment
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Deploy a workflow</td>
<td>Click **Deploy** button in panel</td>
<td><ActionImage src="/static/quick-reference/deploy.png" alt="Deploy workflow" /></td>
</tr>
<tr>
<td>Update deployment</td>
<td>Click **Update** when changes are detected</td>
<td><ActionImage src="/static/quick-reference/update-deployment.png" alt="Update deployment" /></td>
</tr>
<tr>
<td>View deployment status</td>
<td>Check status indicator (Live/Update/Deploy) in Deploy tab</td>
<td><ActionImage src="/static/quick-reference/view-deployment.png" alt="View deployment status" /></td>
</tr>
<tr>
<td>Revert deployment</td>
<td>Access previous versions in Deploy tab → **Promote to live**</td>
<td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td>
</tr>
<tr>
<td>Copy API endpoint</td>
<td>Deploy tab → API → Copy API cURL</td>
<td><ActionImage src="/static/quick-reference/copy-api.png" alt="Copy API endpoint" /></td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Deploy a workflow | Click **Deploy** button in Deploy tab |
| Update deployment | Click **Update** when changes are detected |
| View deployment status | Check status indicator (Live/Update/Deploy) in Deploy tab |
| Revert deployment | Access previous versions in Deploy tab |
| Copy webhook URL | Deploy tab → Copy webhook URL |
| Copy API endpoint | Deploy tab → Copy API endpoint URL |
| Set up a schedule | Add Schedule trigger block → Configure interval |
## Variables
<table>
<thead>
<tr><th>Action</th><th>How</th><th>Preview</th></tr>
</thead>
<tbody>
<tr>
<td>Add / Edit / Delete workflow variable</td>
<td>Panel -> Variables -> **Add Variable**, click to edit, or delete icon</td>
<td><ActionImage src="/static/quick-reference/variables.png" alt="Variables panel" /></td>
</tr>
<tr>
<td>Add environment variable</td>
<td>Settings → **Environment Variables** → **Add**</td>
<td><ActionImage src="/static/quick-reference/add-env-variable.png" alt="Add environment variable" /></td>
</tr>
<tr>
<td>Reference a workflow variable</td>
<td>Use `<blockName.itemName>` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/variable-reference.png" alt="Reference workflow variable" /></td>
</tr>
<tr>
<td>Reference an environment variable</td>
<td>Use `{{ENV_VAR}}` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/env-variable-reference.png" alt="Reference environment variable" /></td>
</tr>
</tbody>
</table>
| Action | How |
|--------|-----|
| Add workflow variable | Variables tab → **Add Variable** |
| Edit workflow variable | Variables tab → Click variable to edit |
| Delete workflow variable | Variables tab → Click delete icon on variable |
| Add environment variable | Settings → **Environment Variables** → **Add** |
| Reference a variable | Use `{{variableName}}` syntax in block inputs |
## Credentials
| Action | How |
|--------|-----|
| Add API key | Block credential field → **Add Credential** → Enter API key |
| Connect OAuth account | Block credential field → **Connect** → Authorize with provider |
| Manage credentials | Settings → **Credentials** |
| Remove credential | Settings → **Credentials** → Delete credential |

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 → Servidores MCP**
1. Navega a **Configuración → MCP implementados**
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 → Servidores MCP**, puedes:
Desde la vista de detalles del servidor en **Configuración → MCP implementados**, 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 **Servidores MCP**
2. Ve a la sección **MCP implementados**
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 → Serveurs MCP**
1. Accédez à **Paramètres → MCP déployés**
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 → Serveurs MCP**, vous pouvez :
Depuis la vue détaillée du serveur dans **Paramètres → MCP déployés**, 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 **Serveurs MCP**
2. Allez dans la section **MCP déployés**
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. 进入 **设置 → MCP 服务器**
1. 进入 **设置 → 已部署的 MCPs**
2. 点击 **创建服务器**
3. 输入名称和可选描述
4. 复制服务器 URL 以在你的 MCP 客户端中使用
@@ -78,7 +78,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
## 服务器管理
在 **设置 → MCP 服务器** 的服务器详情页,你可以:
在 **设置 → 已部署的 MCPs** 的服务器详情页,你可以:
- **查看工具**:查看添加到服务器的所有工作流
- **复制 URL**:获取 MCP 客户端的服务器 URL

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -8,7 +8,6 @@ import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
@@ -388,12 +387,7 @@ function resolveWorkflowVariables(
if (type === 'number') {
variableValue = Number(variableValue)
} else if (type === 'boolean') {
if (typeof variableValue === 'boolean') {
// Already a boolean, keep as-is
} else {
const normalized = String(variableValue).toLowerCase().trim()
variableValue = normalized === 'true'
}
variableValue = variableValue === 'true' || variableValue === true
} else if (type === 'json' && typeof variableValue === 'string') {
try {
variableValue = JSON.parse(variableValue)
@@ -693,7 +687,11 @@ export async function POST(req: NextRequest) {
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n`
if (v === undefined) {
prologue += `const ${k} = undefined;\n`
} else {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
}
prologueLineCount++
}
@@ -764,7 +762,11 @@ export async function POST(req: NextRequest) {
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `${k} = ${formatLiteralForCode(v, 'python')}\n`
if (v === undefined) {
prologue += `${k} = None\n`
} else {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
}
prologueLineCount++
}
const wrapped = [

View File

@@ -16,10 +16,6 @@ mockKnowledgeSchemas()
mockDrizzleOrm()
mockConsoleLogger()
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue({ role: 'owner' }),
}))
describe('Knowledge Base API Route', () => {
const mockAuth$ = mockAuth()
@@ -90,7 +86,6 @@ describe('Knowledge Base API Route', () => {
const validKnowledgeBaseData = {
name: 'Test Knowledge Base',
description: 'Test description',
workspaceId: 'test-workspace-id',
chunkingConfig: {
maxSize: 1024,
minSize: 100,
@@ -138,25 +133,11 @@ 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)
@@ -176,7 +157,7 @@ describe('Knowledge Base API Route', () => {
it('should use default values for optional fields', async () => {
mockAuth$.mockAuthenticatedUser()
const minimalData = { name: 'Test KB', workspaceId: 'test-workspace-id' }
const minimalData = { name: 'Test KB' }
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().min(1, 'Workspace ID is required'),
workspaceId: z.string().optional(),
embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'),
embeddingDimension: z.literal(1536).default(1536),
chunkingConfig: z

View File

@@ -0,0 +1,204 @@
import { db } from '@sim/db'
import { member, permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
const logger = createLogger('OrganizationWorkspacesAPI')
/**
* GET /api/organizations/[id]/workspaces
* Get workspaces related to the organization with optional filtering
* Query parameters:
* - ?available=true - Only workspaces where user can invite others (admin permissions)
* - ?member=userId - Workspaces where specific member has access
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const availableOnly = url.searchParams.get('available') === 'true'
const memberId = url.searchParams.get('member')
// Verify user is a member of this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{
error: 'Forbidden - Not a member of this organization',
},
{ status: 403 }
)
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
if (availableOnly) {
// Get workspaces where user has admin permissions (can invite others)
const availableWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
isOwner: eq(workspace.ownerId, session.user.id),
permissionType: permissions.permissionType,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, session.user.id)
)
)
.where(
or(
// User owns the workspace
eq(workspace.ownerId, session.user.id),
// User has admin permission on the workspace
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.permissionType, 'admin')
)
)
)
// Filter and format the results
const workspacesWithInvitePermission = availableWorkspaces
.filter((workspace) => {
// Include if user owns the workspace OR has admin permission
return workspace.isOwner || workspace.permissionType === 'admin'
})
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
canInvite: true, // All returned workspaces have invite permission
createdAt: workspace.createdAt,
}))
logger.info('Retrieved available workspaces for organization member', {
organizationId,
userId: session.user.id,
workspaceCount: workspacesWithInvitePermission.length,
})
return NextResponse.json({
success: true,
data: {
workspaces: workspacesWithInvitePermission,
totalCount: workspacesWithInvitePermission.length,
filter: 'available',
},
})
}
if (memberId && hasAdminAccess) {
// Get workspaces where specific member has access (admin only)
const memberWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
isOwner: eq(workspace.ownerId, memberId),
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, memberId)
)
)
.where(
or(
// Member owns the workspace
eq(workspace.ownerId, memberId),
// Member has permissions on the workspace
and(eq(permissions.userId, memberId), eq(permissions.entityType, 'workspace'))
)
)
const formattedWorkspaces = memberWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
permission: workspace.permissionType,
joinedAt: workspace.createdAt,
createdAt: workspace.createdAt,
}))
return NextResponse.json({
success: true,
data: {
workspaces: formattedWorkspaces,
totalCount: formattedWorkspaces.length,
filter: 'member',
memberId,
},
})
}
// Default: Get all workspaces (basic info only for regular members)
if (!hasAdminAccess) {
return NextResponse.json({
success: true,
data: {
workspaces: [],
totalCount: 0,
message: 'Workspace access information is only available to organization admins',
},
})
}
// For admins: Get summary of all workspaces
const allWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
ownerName: user.name,
})
.from(workspace)
.leftJoin(user, eq(workspace.ownerId, user.id))
return NextResponse.json({
success: true,
data: {
workspaces: allWorkspaces,
totalCount: allWorkspaces.length,
filter: 'all',
},
userRole,
hasAdminAccess,
})
} catch (error) {
logger.error('Failed to get organization workspaces', { error })
return NextResponse.json(
{
error: 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -1,257 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('SupabaseStorageUploadAPI')
const SupabaseStorageUploadSchema = z.object({
projectId: z.string().min(1, 'Project ID is required'),
apiKey: z.string().min(1, 'API key is required'),
bucket: z.string().min(1, 'Bucket name is required'),
fileName: z.string().min(1, 'File name is required'),
path: z.string().optional().nullable(),
fileData: z.any(),
contentType: z.string().optional().nullable(),
upsert: z.boolean().optional().default(false),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated Supabase storage upload request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = SupabaseStorageUploadSchema.parse(body)
const fileData = validatedData.fileData
const isStringInput = typeof fileData === 'string'
logger.info(`[${requestId}] Uploading to Supabase Storage`, {
bucket: validatedData.bucket,
fileName: validatedData.fileName,
path: validatedData.path,
fileDataType: isStringInput ? 'string' : 'object',
})
if (!fileData) {
return NextResponse.json(
{
success: false,
error: 'fileData is required',
},
{ status: 400 }
)
}
let uploadBody: Buffer
let uploadContentType: string | undefined
if (isStringInput) {
let content = fileData as string
const dataUrlMatch = content.match(/^data:([^;]+);base64,(.+)$/s)
if (dataUrlMatch) {
const [, mimeType, base64Data] = dataUrlMatch
content = base64Data
if (!validatedData.contentType) {
uploadContentType = mimeType
}
logger.info(`[${requestId}] Extracted base64 from data URL (MIME: ${mimeType})`)
}
const cleanedContent = content.replace(/[\s\r\n]/g, '')
const isLikelyBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(cleanedContent)
if (isLikelyBase64 && cleanedContent.length >= 4) {
try {
uploadBody = Buffer.from(cleanedContent, 'base64')
const expectedMinSize = Math.floor(cleanedContent.length * 0.7)
const expectedMaxSize = Math.ceil(cleanedContent.length * 0.8)
if (
uploadBody.length >= expectedMinSize &&
uploadBody.length <= expectedMaxSize &&
uploadBody.length > 0
) {
logger.info(
`[${requestId}] Decoded base64 content: ${cleanedContent.length} chars -> ${uploadBody.length} bytes`
)
} else {
const reEncoded = uploadBody.toString('base64')
if (reEncoded !== cleanedContent) {
logger.info(
`[${requestId}] Content looked like base64 but re-encoding didn't match, using as plain text`
)
uploadBody = Buffer.from(content, 'utf-8')
} else {
logger.info(
`[${requestId}] Decoded base64 content (verified): ${uploadBody.length} bytes`
)
}
}
} catch (decodeError) {
logger.info(
`[${requestId}] Failed to decode as base64, using as plain text: ${decodeError}`
)
uploadBody = Buffer.from(content, 'utf-8')
}
} else {
uploadBody = Buffer.from(content, 'utf-8')
logger.info(`[${requestId}] Using content as plain text (${uploadBody.length} bytes)`)
}
uploadContentType =
uploadContentType || validatedData.contentType || 'application/octet-stream'
} else {
const rawFile = fileData
logger.info(`[${requestId}] Processing file object: ${rawFile.name || 'unknown'}`)
let userFile
try {
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to process file',
},
{ status: 400 }
)
}
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
uploadBody = buffer
uploadContentType = validatedData.contentType || userFile.type || 'application/octet-stream'
}
let fullPath = validatedData.fileName
if (validatedData.path) {
const folderPath = validatedData.path.endsWith('/')
? validatedData.path
: `${validatedData.path}/`
fullPath = `${folderPath}${validatedData.fileName}`
}
const supabaseUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}`
const headers: Record<string, string> = {
apikey: validatedData.apiKey,
Authorization: `Bearer ${validatedData.apiKey}`,
'Content-Type': uploadContentType,
}
if (validatedData.upsert) {
headers['x-upsert'] = 'true'
}
logger.info(`[${requestId}] Sending to Supabase: ${supabaseUrl}`, {
contentType: uploadContentType,
bodySize: uploadBody.length,
upsert: validatedData.upsert,
})
const response = await fetch(supabaseUrl, {
method: 'POST',
headers,
body: new Uint8Array(uploadBody),
})
if (!response.ok) {
const errorText = await response.text()
let errorData
try {
errorData = JSON.parse(errorText)
} catch {
errorData = { message: errorText }
}
logger.error(`[${requestId}] Supabase Storage upload failed:`, {
status: response.status,
statusText: response.statusText,
error: errorData,
})
return NextResponse.json(
{
success: false,
error: errorData.message || errorData.error || `Upload failed: ${response.statusText}`,
details: errorData,
},
{ status: response.status }
)
}
const result = await response.json()
logger.info(`[${requestId}] File uploaded successfully to Supabase Storage`, {
bucket: validatedData.bucket,
path: fullPath,
})
const publicUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}`
return NextResponse.json({
success: true,
output: {
message: 'Successfully uploaded file to storage',
results: {
...result,
path: fullPath,
bucket: validatedData.bucket,
publicUrl,
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error uploading to Supabase Storage:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ 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 { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { WorkflowPreview } 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 (
<PreviewWorkflow
<WorkflowPreview
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 { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { WorkflowPreview } 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,14 +200,13 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<PreviewWorkflow
<WorkflowPreview
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, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
@@ -13,8 +13,13 @@ import {
PopoverContent,
PopoverItem,
} from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview'
import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -27,6 +32,13 @@ interface TraceSpan {
children?: TraceSpan[]
}
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
}
interface MigratedWorkflowState extends WorkflowState {
_migrated: true
_note?: string
@@ -58,35 +70,98 @@ export function ExecutionSnapshot({
onClose = () => {},
}: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId)
const lastExecutionIdRef = useRef<string | null>(null)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const autoSelectedForExecutionRef = 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
// Track execution ID changes for key reset
const executionKey = executionId !== lastExecutionIdRef.current ? executionId : undefined
if (executionId !== lastExecutionIdRef.current) {
lastExecutionIdRef.current = executionId
}
// 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])
const renderContent = () => {
if (isLoading) {
@@ -151,17 +226,44 @@ export function ExecutionSnapshot({
}
return (
<Preview
key={executionKey}
workflowState={workflowState}
traceSpans={traceSpans}
className={className}
height={height}
width={width}
onCanvasContextMenu={handleCanvasContextMenu}
showBorder={!isModal}
autoSelectLeftmost
/>
<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>
)
}
@@ -185,6 +287,9 @@ 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>,
@@ -199,6 +304,7 @@ 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 { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { WorkflowPreview } 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 ? (
<PreviewWorkflow
<WorkflowPreview
workflowState={normalizedState}
height={180}
width='100%'
@@ -214,7 +214,6 @@ 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

@@ -25,277 +25,18 @@ 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'
}
/**
* Get embed info for supported media platforms
* Extract YouTube video ID from various YouTube URL formats
*/
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' }
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]
}
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
}
@@ -367,57 +108,29 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
)
},
a: ({ href, children }: any) => {
const embedInfo = href ? getEmbedInfo(href) : null
if (embedInfo) {
const videoId = href ? getYouTubeVideoId(href) : null
if (videoId) {
return (
<span className='my-2 block w-full'>
<span className='inline'>
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
className='text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
<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 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>
</span>
)
@@ -427,7 +140,7 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
href={href}
target='_blank'
rel='noopener noreferrer'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
className='text-[var(--brand-secondary)] underline-offset-2 hover:underline'
>
{children}
</a>
@@ -446,26 +159,6 @@ 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}
@@ -478,7 +171,7 @@ export const NoteBlock = memo(function NoteBlock({
data,
selected,
}: NodeProps<NoteBlockNodeData>) {
const { type, name } = data
const { type, config, name } = data
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
blockId: id,
@@ -555,8 +248,8 @@ export const NoteBlock = memo(function NoteBlock({
</div>
</div>
<div className='relative overflow-hidden p-[8px]'>
<div className='relative max-w-full break-all'>
<div className='relative p-[8px]'>
<div className='relative break-words'>
{isEmpty ? (
<p className='text-[#868686] text-sm'>Add note...</p>
) : (

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -17,7 +17,11 @@ import {
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} 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'
@@ -55,6 +59,8 @@ 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)
@@ -129,6 +135,19 @@ 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]'>
@@ -186,7 +205,7 @@ export function GeneralDeploy({
{workflowToShow ? (
<>
<div className='[&_*]:!cursor-default h-full w-full cursor-default'>
<PreviewWorkflow
<WorkflowPreview
workflowState={workflowToShow}
height='100%'
width='100%'
@@ -287,15 +306,46 @@ export function GeneralDeploy({
</Modal>
{workflowToShow && (
<Modal open={showExpandedPreview} onOpenChange={setShowExpandedPreview}>
<Modal
open={showExpandedPreview}
onOpenChange={(open) => {
if (!open) {
setExpandedSelectedBlockId(null)
}
setShowExpandedPreview(open)
}}
>
<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 overflow-hidden'>
<Preview workflowState={workflowToShow} autoSelectLeftmost />
<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>
</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 MCP Servers first.
Create an MCP Server in Settings Deployed MCPs 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 { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import {
useCreateTemplate,
@@ -439,14 +439,13 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
}}
aria-hidden='true'
>
<PreviewWorkflow
<WorkflowPreview
workflowState={workflowState}
height='100%'
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)
@@ -478,7 +477,7 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
}
return (
<PreviewWorkflow
<WorkflowPreview
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
height='100%'

View File

@@ -39,8 +39,6 @@ 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')
@@ -214,6 +212,7 @@ 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
@@ -221,12 +220,8 @@ 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 =
@@ -301,15 +296,6 @@ 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: () => {
@@ -361,10 +347,9 @@ export const Code = memo(function Code({
setCode(generatedCode)
if (!isPreview && !disabled) {
setStoreValue(generatedCode)
recordReplace(generatedCode)
}
}
}, [disabled, isPreview, recordReplace, setStoreValue])
}, [isPreview, disabled, setStoreValue])
useEffect(() => {
if (!editorRef.current) return
@@ -507,7 +492,7 @@ export const Code = memo(function Code({
setCode(newValue)
setStoreValue(newValue)
recordChange(newValue)
hasEditedSinceFocusRef.current = true
const newCursorPosition = dropPosition + 1
setCursorPosition(newCursorPosition)
@@ -536,7 +521,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
recordChange(newValue)
hasEditedSinceFocusRef.current = true
}
setShowTags(false)
setActiveSourceBlockId(null)
@@ -554,7 +539,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
recordChange(newValue)
hasEditedSinceFocusRef.current = true
}
setShowEnvVars(false)
@@ -640,9 +625,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) {
@@ -661,7 +646,7 @@ export const Code = memo(function Code({
}
}
},
[isAiStreaming, isPreview, disabled, readOnly, recordChange, setStoreValue]
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
)
const handleKeyDown = useCallback(
@@ -672,39 +657,21 @@ export const Code = memo(function Code({
}
if (isAiStreaming) {
e.preventDefault()
return
}
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) {
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
e.preventDefault()
e.stopPropagation()
undo()
return
}
if (isRedo) {
e.preventDefault()
e.stopPropagation()
redo()
}
},
[isAiStreaming, isFunctionCode, redo, undo]
[isAiStreaming]
)
const handleEditorFocus = useCallback(() => {
startSession(codeRef.current)
hasEditedSinceFocusRef.current = false
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
setShowTags(true)
setCursorPosition(0)
}
}, [disabled, isPreview, readOnly, startSession])
const handleEditorBlur = useCallback(() => {
flushPending()
}, [flushPending])
}, [isPreview, disabled, readOnly])
/**
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
@@ -824,7 +791,6 @@ export const Code = memo(function Code({
onValueChange={handleValueChange}
onKeyDown={handleKeyDown}
onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
highlight={highlightCode}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
/>

View File

@@ -338,11 +338,6 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
const configEqual =
prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type
const canonicalToggleEqual =
!!prevProps.canonicalToggle === !!nextProps.canonicalToggle &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
return (
prevProps.blockId === nextProps.blockId &&
configEqual &&
@@ -351,7 +346,8 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
canonicalToggleEqual
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.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 { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { WorkflowPreview } 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'>
<PreviewWorkflow
<WorkflowPreview
workflowState={childWorkflowState}
height={160}
width='100%'
@@ -466,7 +466,6 @@ export function Editor() {
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
lightweight
/>
</div>
<Tooltip.Root>

View File

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

View File

@@ -6,7 +6,6 @@ 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
@@ -91,17 +90,15 @@ 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) {
@@ -154,14 +151,4 @@ 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

@@ -1641,36 +1641,51 @@ const WorkflowContent = React.memo(() => {
}, [screenToFlowPosition, handleToolbarDrop])
/**
* Focus canvas on changed blocks when diff appears.
* Focus canvas on changed blocks when diff appears
* Focuses on new/edited blocks rather than fitting the entire workflow
*/
const pendingZoomBlockIdsRef = useRef<Set<string> | null>(null)
const prevDiffReadyRef = useRef(false)
// Phase 1: When diff becomes ready, record which blocks we want to zoom to
// Phase 2 effect is located after displayNodes is defined (search for "Phase 2")
useEffect(() => {
// Only focus when diff transitions from not ready to ready
if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) {
// Diff just became ready - record blocks to zoom to
const changedBlockIds = [
...(diffAnalysis.new_blocks || []),
...(diffAnalysis.edited_blocks || []),
]
if (changedBlockIds.length > 0) {
pendingZoomBlockIdsRef.current = new Set(changedBlockIds)
const allNodes = getNodes()
const changedNodes = allNodes.filter((node) => changedBlockIds.includes(node.id))
if (changedNodes.length > 0) {
logger.info('Diff ready - focusing on changed blocks', {
changedBlockIds,
foundNodes: changedNodes.length,
})
requestAnimationFrame(() => {
fitViewToBounds({
nodes: changedNodes,
duration: 600,
padding: 0.1,
minZoom: 0.5,
maxZoom: 1.0,
})
})
} else {
logger.info('Diff ready - no changed nodes found, fitting all')
requestAnimationFrame(() => {
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
} else {
// No specific blocks to focus on, fit all after a frame
pendingZoomBlockIdsRef.current = null
logger.info('Diff ready - no changed blocks, fitting all')
requestAnimationFrame(() => {
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
} else if (!isDiffReady && prevDiffReadyRef.current) {
// Diff was cleared (accepted/rejected) - cancel any pending zoom
pendingZoomBlockIdsRef.current = null
}
prevDiffReadyRef.current = isDiffReady
}, [isDiffReady, diffAnalysis, fitViewToBounds])
}, [isDiffReady, diffAnalysis, fitViewToBounds, getNodes])
/** Displays trigger warning notifications. */
useEffect(() => {
@@ -2078,48 +2093,6 @@ const WorkflowContent = React.memo(() => {
})
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
// Phase 2: When displayNodes updates, check if pending zoom blocks are ready
// (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined)
useEffect(() => {
const pendingBlockIds = pendingZoomBlockIdsRef.current
if (!pendingBlockIds || pendingBlockIds.size === 0) {
return
}
// Find the nodes we're waiting for
const pendingNodes = displayNodes.filter((node) => pendingBlockIds.has(node.id))
// Check if all expected nodes are present with valid dimensions
const allNodesReady =
pendingNodes.length === pendingBlockIds.size &&
pendingNodes.every(
(node) =>
typeof node.width === 'number' &&
typeof node.height === 'number' &&
node.width > 0 &&
node.height > 0
)
if (allNodesReady) {
logger.info('Diff ready - focusing on changed blocks', {
changedBlockIds: Array.from(pendingBlockIds),
foundNodes: pendingNodes.length,
})
// Clear pending state before zooming to prevent re-triggers
pendingZoomBlockIdsRef.current = null
// Use requestAnimationFrame to ensure React has finished rendering
requestAnimationFrame(() => {
fitViewToBounds({
nodes: pendingNodes,
duration: 600,
padding: 0.1,
minZoom: 0.5,
maxZoom: 1.0,
})
})
}
}, [displayNodes, fitViewToBounds])
/** Handles ActionBar remove-from-subflow events. */
useEffect(() => {
const handleRemoveFromSubflow = (event: Event) => {

View File

@@ -1,6 +1,6 @@
'use client'
import { type CSSProperties, memo, useMemo } from 'react'
import { memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
@@ -23,27 +23,6 @@ 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
@@ -56,8 +35,6 @@ 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
}
/**
@@ -180,17 +157,21 @@ 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' &&
@@ -223,16 +204,21 @@ 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)
@@ -272,7 +258,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
isPreviewSelected = false,
executionStatus,
subBlockValues,
lightweight = false,
} = data
const blockConfig = getBlock(type)
@@ -283,68 +268,44 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
)
const rawValues = useMemo(() => {
if (lightweight || !subBlockValues) return {}
if (!subBlockValues) return {}
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
acc[key] = extractValue(entry)
return acc
}, {})
}, [subBlockValues, lightweight])
}, [subBlockValues])
const visibleSubBlocks = useMemo(() => {
if (!blockConfig?.subBlocks) return []
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
const effectiveTrigger = isTrigger || type === 'starter'
const isStarterOrTrigger =
blockConfig.category === 'triggers' || type === 'starter' || isTrigger
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden) return false
if (subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) 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
// Handle trigger mode visibility
if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false
// 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)
})
}, [
lightweight,
blockConfig?.subBlocks,
blockConfig?.triggers?.enabled,
blockConfig?.category,
type,
isTrigger,
canonicalIndex,
rawValues,
])
}, [blockConfig?.subBlocks, blockConfig?.category, type, isTrigger, canonicalIndex, rawValues])
/**
* Compute condition rows for condition blocks.
* In lightweight mode, returns default structure without parsing values.
* Compute condition rows for condition blocks
*/
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
@@ -364,24 +325,21 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
}
} catch {
/* empty */
// Failed to parse, use fallback
}
return defaultRows
}, [type, rawValues, lightweight])
return [
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
}, [type, rawValues])
/**
* Compute router rows for router_v2 blocks.
* In lightweight mode, returns default structure without parsing values.
* Compute router rows for router_v2 blocks
*/
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
@@ -399,11 +357,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
}
} catch {
/* empty */
// Failed to parse, use fallback
}
return defaultRows
}, [type, rawValues, lightweight])
return [{ id: 'route1', value: '' }]
}, [type, rawValues])
if (!blockConfig) {
return null
@@ -421,6 +379,9 @@ 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'
@@ -445,7 +406,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
id='target'
className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
style={
horizontalHandles
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
@@ -478,37 +439,36 @@ 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={lightweight ? undefined : getDisplayValue(cond.value)}
/>
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
))
) : type === 'router_v2' ? (
// Router block: render context + route rows
<>
<SubBlockRow
key='context'
title='Context'
value={lightweight ? undefined : getDisplayValue(rawValues.context)}
value={getDisplayValue(rawValues.context)}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={route.id}
title={`Route ${index + 1}`}
value={lightweight ? undefined : getDisplayValue(route.value)}
value={getDisplayValue(route.value)}
/>
))}
</>
) : (
// Standard blocks: render visible subblocks
visibleSubBlocks.map((subBlock) => {
const rawValue = lightweight ? undefined : rawValues[subBlock.id]
const rawValue = rawValues[subBlock.id]
return (
<SubBlockRow
key={subBlock.id}
title={subBlock.title ?? subBlock.id}
value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={lightweight ? undefined : subBlock}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
/>
)
@@ -519,101 +479,27 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
</div>
)}
{/* 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}
/>
)}
</>
)}
{/* 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%)' }
}
/>
</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 ||
@@ -622,41 +508,38 @@ 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.lightweight !== nextProps.data.lightweight
prevProps.data.executionStatus !== nextProps.data.executionStatus
) {
return false
}
/** Skip subBlockValues comparison in lightweight mode */
if (nextProps.data.lightweight) return true
// Compare subBlockValues by reference first
const prevValues = prevProps.data.subBlockValues
const nextValues = nextProps.data.subBlockValues
if (prevValues === nextValues) return true
if (!prevValues || !nextValues) return false
if (prevValues === nextValues) {
return true
}
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
}
/**
* 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)
export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)

View File

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

View File

@@ -1,97 +0,0 @@
'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

@@ -6,24 +6,12 @@ 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,
Tooltip,
} from '@/components/emcn'
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
@@ -34,42 +22,15 @@ 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, 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;
}
`
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
/**
* Format a value for display as JSON string
@@ -162,31 +123,44 @@ function formatInlineValue(value: unknown): string {
return String(value)
}
interface CollapsibleSectionProps {
interface ExecutionDataSectionProps {
title: string
defaultExpanded?: boolean
children: React.ReactNode
isEmpty?: boolean
emptyMessage?: string
/** Whether this section represents an error state (styles title red) */
data: unknown
isError?: boolean
wrapText?: boolean
searchQuery?: string
currentMatchIndex?: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
onContextMenu?: (e: React.MouseEvent) => void
}
/**
* Collapsible section wrapper for organizing preview editor content
* Collapsible section for execution data (input/output)
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
*/
function CollapsibleSection({
function ExecutionDataSection({
title,
defaultExpanded = false,
children,
isEmpty = false,
emptyMessage = 'No data',
data,
isError = false,
}: CollapsibleSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
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 === ''
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => setIsExpanded(!isExpanded)}
@@ -225,10 +199,20 @@ function CollapsibleSection({
<>
{isEmpty ? (
<div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
<span className='text-[12px] text-[var(--text-tertiary)]'>{emptyMessage}</span>
<span className='text-[12px] text-[var(--text-tertiary)]'>No data</span>
</div>
) : (
children
<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>
)}
</>
)}
@@ -277,12 +261,9 @@ 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(connectionIds.split(',').filter(Boolean)))
}, [connectionIds])
setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
}, [connections])
const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0
@@ -568,22 +549,27 @@ 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') {
@@ -603,6 +589,7 @@ 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`
@@ -614,7 +601,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
}
return (
<div className='flex-1 overflow-y-auto overflow-x-hidden pt-[8px] pb-[8px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden pt-[5px] pb-[8px]'>
{/* Type Selection - matches SubflowEditor */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
@@ -716,8 +703,6 @@ 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) */
@@ -740,8 +725,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)
@@ -750,39 +735,10 @@ 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)
@@ -890,8 +846,10 @@ function PreviewEditorContent({
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
const deltaY = startYRef.current - e.clientY
const deltaY = startYRef.current - e.clientY // Inverted because we're resizing from bottom up
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)
}
@@ -913,6 +871,7 @@ function PreviewEditorContent({
}
}, [isResizing])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5
const blockNameToId = useMemo(() => {
@@ -932,7 +891,7 @@ function PreviewEditorContent({
if (!allBlockExecutions || !workflowBlocks) return undefined
if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
const inner = reference.slice(1, -1)
const inner = reference.slice(1, -1) // Remove < and >
const parts = inner.split('.')
if (parts.length < 1) return undefined
@@ -1048,10 +1007,12 @@ 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
@@ -1082,7 +1043,27 @@ 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]'>
<style>{READONLY_PREVIEW_STYLES}</style>
{/* 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>
<SubflowConfigDisplay block={block} loop={loopConfig} parallel={parallelConfig} />
</div>
</div>
@@ -1114,6 +1095,8 @@ 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 (
@@ -1162,7 +1145,7 @@ function PreviewEditorContent({
{/* Content area */}
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Main content sections */}
{/* Subblocks Section */}
<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 */}
@@ -1176,154 +1159,91 @@ function PreviewEditorContent({
</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>
{/* 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>
)}
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs, { precision: 2 })}
</span>
)}
</div>
)}
{/* 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]'
{/* 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}
/>
</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)]'
)}
{/* 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}
/>
</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>
)}
) : null}
{/* Subblock Values - Using SubBlock components in preview mode */}
<div className='readonly-preview px-[8px] pt-[12px] pb-[8px]'>
<style>{READONLY_PREVIEW_STYLES}</style>
<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>
{visibleSubBlocks.length > 0 ? (
<div className='flex flex-col'>
{visibleSubBlocks.map((subBlockConfig, index) => (
@@ -1429,7 +1349,7 @@ function PreviewEditorContent({
)}
{/* Context Menu */}
<PreviewContextMenu
<SnapshotContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}

View File

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

View File

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

View File

@@ -1,613 +0,0 @@
'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

@@ -5,9 +5,6 @@ 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
@@ -15,10 +12,6 @@ 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
}
/**
@@ -27,7 +20,7 @@ interface WorkflowPreviewSubflowData {
* or interactive features.
*/
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const { name, width = 500, height = 300, kind, isPreviewSelected = false, executionStatus } = data
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
@@ -42,9 +35,6 @@ 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)]'
@@ -53,18 +43,10 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
height,
}}
>
{/* Selection ring overlay (takes priority over execution rings) */}
{/* Selection ring overlay */}
{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
@@ -128,4 +110,4 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
)
}
export const PreviewSubflow = memo(WorkflowPreviewSubflowInner)
export const WorkflowPreviewSubflow = memo(WorkflowPreviewSubflowInner)

View File

@@ -1,6 +1,2 @@
export { PreviewContextMenu } from './components/preview-context-menu'
export { PreviewEditor } from './components/preview-editor'
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'
export { getLeftmostBlockId, WorkflowPreview } from './preview'

View File

@@ -1,292 +1,499 @@
'use client'
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 { 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 { 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'
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'
interface TraceSpan {
blockId?: string
input?: unknown
output?: unknown
status?: string
duration?: number
children?: TraceSpan[]
}
const logger = createLogger('WorkflowPreview')
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
/** Child trace spans for nested workflow blocks */
children?: TraceSpan[]
}
/**
* 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,
}
}
/** Represents a level in the workflow navigation stack */
interface WorkflowStackEntry {
workflowState: WorkflowState
traceSpans: TraceSpan[]
blockExecutions: Record<string, BlockExecutionData>
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
return estimateBlockDimensions(block.type)
}
/**
* 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.
* Calculates container dimensions based on child block positions and sizes.
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
*/
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
if (!blockExecution) return []
function calculateContainerDimensions(
containerId: string,
blocks: Record<string, BlockState>
): { width: number; height: number } {
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
if (Array.isArray(blockExecution.children) && blockExecution.children.length > 0) {
return blockExecution.children
}
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[]
if (childBlocks.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
return []
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 }
}
/**
* Builds block execution data from trace spans
* Finds the leftmost block ID from a workflow state.
* Returns the block with the smallest x position, excluding subflow containers (loop/parallel).
*/
export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockExecutionData> {
const blockExecutionMap: Record<string, BlockExecutionData> = {}
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
if (!workflowState?.blocks) return null
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
}
let leftmostId: string | null = null
let minX = Number.POSITIVE_INFINITY
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,
}
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 blockExecutionMap
return leftmostId
}
interface PreviewProps {
/** The workflow state to display */
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewProps {
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 */
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
/** 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
/** 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
}
/**
* 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
* Preview node types using minimal components without hooks or store subscriptions.
* This prevents interaction issues while allowing canvas panning and node clicking.
*/
export function Preview({
workflowState: rootWorkflowState,
traceSpans: rootTraceSpans,
blockExecutions: providedBlockExecutions,
const previewNodeTypes: NodeTypes = {
workflowBlock: WorkflowPreviewBlock,
noteBlock: WorkflowPreviewBlock,
subflowNode: WorkflowPreviewSubflow,
}
// Define edge types
const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge, // Keep for backward compatibility
}
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()
}
}, [containerRef, fitPadding, fitView])
return null
}
export function WorkflowPreview({
workflowState,
className,
height = '100%',
width = '100%',
onCanvasContextMenu,
isPannable = true,
defaultPosition,
defaultZoom = 0.8,
fitPadding = 0.25,
onNodeClick,
onNodeContextMenu,
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)
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(','),
}
return null
})
}, [workflowState.blocks, isValidWorkflowState])
/** 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
const loopsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
}
return rootBlockExecutions
}, [workflowStack, rootBlockExecutions])
}, [workflowState.loops, isValidWorkflowState])
/** Current workflow state - either from stack or root */
const workflowState = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].workflowState
const parallelsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
}
return rootWorkflowState
}, [workflowStack, rootWorkflowState])
}, [workflowState.parallels, isValidWorkflowState])
/** Whether we're in execution mode (have trace spans/block executions) */
const isExecutionMode = useMemo(() => {
return Object.keys(blockExecutions).length > 0
}, [blockExecutions])
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])
/** 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 calculateAbsolutePosition = (
block: any,
blocks: Record<string, any>
): { x: number; y: number } => {
if (!block.data?.parentId) {
return block.position
}
setWorkflowStack((prev) => [
...prev,
{
workflowState: childWorkflowState,
traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions,
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,
},
])
})
})
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId)
},
[blockExecutions]
)
return nodeArray
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
workflowState.blocks,
isValidWorkflowState,
executedBlocks,
selectedBlockId,
])
/** Handler to go back up the stack */
const handleGoBack = useCallback(() => {
setWorkflowStack((prev) => prev.slice(0, -1))
setPinnedBlockId(null)
}, [])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
const handleNodeClick = useCallback((blockId: string) => {
setPinnedBlockId(blockId)
}, [])
return (workflowState.edges || []).map((edge) => {
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const sourceExecuted = executedBlocks[edge.source]
const targetExecuted = executedBlocks[edge.target]
const handlePaneClick = useCallback(() => {
setPinnedBlockId(null)
}, [])
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 handleEditorClose = 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])
useEffect(() => {
setWorkflowStack([])
}, [rootWorkflowState])
const isNested = workflowStack.length > 0
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 (
<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>
)}
<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 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}
/* 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>
{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>
</ReactFlowProvider>
)
}

View File

@@ -185,16 +185,6 @@ 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']
@@ -275,8 +265,14 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
.replace(/[^a-z0-9-]/g, '')
const config = isPublic
? { url: mcpServerUrl }
: { url: mcpServerUrl, headers: { 'X-API-Key': '$SIM_API_KEY' } }
? {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl],
}
: {
command: 'npx',
args: ['-y', 'mcp-remote', mcpServerUrl, '--header', '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: 'MCP Servers', icon: Server, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
{
id: 'byok',
label: 'BYOK',

View File

@@ -214,6 +214,15 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
],
config: {
tool: (params) => params.operation as string,
params: (params) => {
const { fileUpload, fileReference, ...rest } = params
const hasFileUpload = Array.isArray(fileUpload) ? fileUpload.length > 0 : !!fileUpload
const files = hasFileUpload ? fileUpload : fileReference
return {
...rest,
...(files ? { files } : {}),
}
},
},
},
inputs: {

View File

@@ -513,12 +513,6 @@ 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',
@@ -760,7 +754,6 @@ 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

@@ -581,18 +581,6 @@ export const GmailV2Block: BlockConfig<GmailToolResponse> = {
results: { type: 'json', description: 'Search/read summary results' },
attachments: { type: 'json', description: 'Downloaded attachments (if enabled)' },
// Draft-specific outputs
draftId: {
type: 'string',
description: 'Draft ID',
condition: { field: 'operation', value: 'draft_gmail' },
},
messageId: {
type: 'string',
description: 'Gmail message ID for the draft',
condition: { field: 'operation', value: 'draft_gmail' },
},
// Trigger outputs (unchanged)
email_id: { type: 'string', description: 'Gmail message ID' },
thread_id: { type: 'string', description: 'Gmail thread ID' },

View File

@@ -661,25 +661,12 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
placeholder: 'folder/subfolder/',
condition: { field: 'operation', value: 'storage_upload' },
},
{
id: 'file',
title: 'File',
type: 'file-upload',
canonicalParamId: 'fileData',
placeholder: 'Upload file to storage',
condition: { field: 'operation', value: 'storage_upload' },
mode: 'basic',
multiple: false,
required: true,
},
{
id: 'fileContent',
title: 'File Content',
type: 'code',
canonicalParamId: 'fileData',
placeholder: 'Base64 encoded for binary files, or plain text',
condition: { field: 'operation', value: 'storage_upload' },
mode: 'advanced',
required: true,
},
{

View File

@@ -10,7 +10,6 @@ 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'
@@ -37,11 +36,6 @@ 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
@@ -52,261 +46,6 @@ 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.
*/
@@ -517,64 +256,28 @@ function Placeholder({ children, gutterWidth, show, className }: CodePlaceholder
}
/**
* Represents a highlighted line of code.
* Props for virtualized row rendering.
*/
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,
showCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse,
} = props
const { lines, gutterWidth, showGutter, gutterStyle, leftOffset, wrapText } = 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}>
@@ -586,19 +289,6 @@ 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]',
@@ -612,12 +302,6 @@ 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,
@@ -682,8 +366,6 @@ 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
}
/**
@@ -740,41 +422,42 @@ 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,
@@ -788,7 +471,6 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const listRef = useListRef(null)
@@ -799,70 +481,52 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
key: wrapText ? 'wrap' : 'nowrap',
})
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])
const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// Only process visible lines for efficiency (not all lines)
const visibleLines = useMemo(() => {
const lines = useMemo(() => code.split('\n'), [code])
const lineCount = lines.length
const gutterWidth = useMemo(() => calculateGutterWidth(lineCount), [lineCount])
const highlightedLines = useMemo(() => {
const lang = languages[language] || languages.javascript
const hasSearch = searchQuery?.trim()
return lines.map((line, idx) => ({
lineNumber: idx + 1,
html: highlight(line, lang, language),
}))
}, [lines, language])
return visibleLineIndices.map((idx) => {
let html = highlight(displayLines[idx], lang, language)
const matchOffsets = useMemo(() => {
if (!searchQuery?.trim()) return []
const offsets: number[] = []
let cumulative = 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
if (hasSearch && searchQuery) {
const result = applySearchHighlightingToLine(
html,
searchQuery,
currentMatchIndex,
matchOffsets[idx]
)
html = result.html
}
for (const line of lines) {
offsets.push(cumulative)
const matches = line.match(regex)
cumulative += matches?.length ?? 0
}
return offsets
}, [lines, searchQuery])
return { lineNumber: idx + 1, 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 }
})
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex, matchOffsets])
}, [highlightedLines, searchQuery, currentMatchIndex, matchOffsets])
useEffect(() => {
if (!searchQuery?.trim() || matchCount === 0 || !listRef.current) return
@@ -871,15 +535,12 @@ 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) {
const visibleIndex = visibleLineIndices.indexOf(i)
if (visibleIndex !== -1) {
listRef.current.scrollToRow({ index: visibleIndex, align: 'center' })
}
listRef.current.scrollToRow({ index: i, align: 'center' })
break
}
accumulated += matchesInThisLine
}
}, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef, visibleLineIndices])
}, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef])
useEffect(() => {
const container = containerRef.current
@@ -888,11 +549,15 @@ 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()
}, [])
@@ -906,7 +571,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
if (rows.length === 0) return
return dynamicRowHeight.observeRowElements(rows)
}, [wrapText, dynamicRowHeight, visibleLines])
}, [wrapText, dynamicRowHeight, linesWithSearch])
const setRefs = useCallback(
(el: HTMLDivElement | null) => {
@@ -920,29 +585,14 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
const rowProps = useMemo(
() => ({
lines: visibleLines,
lines: linesWithSearch,
gutterWidth,
showGutter,
gutterStyle,
leftOffset: paddingLeft,
wrapText,
showCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse: toggleCollapse,
}),
[
visibleLines,
gutterWidth,
showGutter,
gutterStyle,
paddingLeft,
wrapText,
showCollapseColumn,
collapsibleLines,
collapsedLines,
toggleCollapse,
]
[linesWithSearch, gutterWidth, showGutter, gutterStyle, paddingLeft, wrapText]
)
return (
@@ -959,7 +609,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
<List
listRef={listRef}
defaultHeight={containerHeight}
rowCount={visibleLines.length}
rowCount={lineCount}
rowHeight={wrapText ? dynamicRowHeight : CODE_LINE_HEIGHT_PX}
rowComponent={CodeRow}
rowProps={rowProps}
@@ -997,9 +647,6 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
*/
/**
* Non-virtualized code viewer implementation.
* Renders all lines directly without windowing.
*
* @param props - Viewer props
*/
function ViewerInner({
code,
@@ -1013,96 +660,23 @@ function ViewerInner({
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
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])
// Compute match count and notify parent
const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// 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])
// Determine whitespace class based on wrap setting
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
const collapseColumnWidth = showCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
// Grid-based rendering for gutter alignment (works with wrap)
if (showGutter) {
// 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 }
return (
<Container className={className}>
<Content className='code-editor-theme' editorRef={contentRef}>
@@ -1112,40 +686,37 @@ function ViewerInner({
paddingTop: '8px',
paddingBottom: '8px',
display: 'grid',
gridTemplateColumns: showCollapseColumn
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
: `${gutterWidth}px 1fr`,
gridTemplateColumns: `${gutterWidth}px 1fr`,
}}
>
{highlightedVisibleLines.map(({ lineNumber, html }) => {
const idx = lineNumber - 1
const isCollapsible = collapsibleLines.has(idx)
const isCollapsed = collapsedLines.has(idx)
{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
)
}
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={gutterStyle}
style={{ transform: 'translateY(0.25px)', ...gutterStyle }}
>
{lineNumber}
{idx + 1}
</div>
{showCollapseColumn && (
<div className='ml-1 flex items-start justify-end'>
{isCollapsible && (
<CollapseButton
isCollapsed={isCollapsed}
onClick={() => toggleCollapse(idx)}
/>
)}
</div>
)}
<pre
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 }}
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;' }}
/>
</Fragment>
)
@@ -1156,16 +727,69 @@ function ViewerInner({
)
}
// Simple display without gutter
// 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>
)
}
return (
<Container className={className}>
<Content className='code-editor-theme' editorRef={contentRef}>
<Gutter width={gutterWidth} style={{ left: `${paddingLeft}px`, ...gutterStyle }}>
{lineNumbers}
</Gutter>
<Content
className='code-editor-theme'
paddingLeft={`${gutterWidth + paddingLeft}px`}
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>
@@ -1176,9 +800,6 @@ 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,
@@ -1193,7 +814,6 @@ function Viewer({
onMatchCountChange,
contentRef,
virtualized = false,
showCollapseColumn = false,
}: CodeViewerProps) {
const innerProps: ViewerInnerProps = {
code,
@@ -1207,7 +827,6 @@ function Viewer({
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}
return virtualized ? <VirtualizedViewerInner {...innerProps} /> : <ViewerInner {...innerProps} />

View File

@@ -5,8 +5,9 @@ import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/core/utils/cn'
/**
* Switch component styled to match Sim's design system.
* Uses brand color for checked state, neutral border for unchecked.
* Custom switch component with thin track design.
* Track: 28px width, 6px height, 20px border-radius
* Thumb: 14px diameter circle that overlaps the track
*/
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -15,13 +16,21 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root
disabled={disabled}
className={cn(
'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',
'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)]',
className
)}
{...props}
ref={ref}
>
<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.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.Root>
))

View File

@@ -275,26 +275,6 @@ 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)

Some files were not shown because too many files have changed in this diff Show More