diff --git a/apps/docs/app/api/og/route.tsx b/apps/docs/app/api/og/route.tsx index ec58754d9b3..ee99eec89e9 100644 --- a/apps/docs/app/api/og/route.tsx +++ b/apps/docs/app/api/og/route.tsx @@ -5,16 +5,19 @@ import type { NextRequest } from 'next/server' export const runtime = 'edge' const TITLE_FONT_SIZE = { - large: 105, - medium: 91, - small: 81, + large: 110, + medium: 96, + small: 85, } as const /** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */ -const AVG_CHAR_WIDTH_EM = 0.46 +const LATIN_CHAR_WIDTH_EM = 0.42 +/** CJK glyphs (docs ships `ja`/`zh` locales) render near-square, roughly 2.4x a Latin glyph at this weight. */ +const CJK_CHAR_WIDTH_EM = 1 +const CJK_RANGE = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/ const TITLE_BOX_WIDTH = 1020 const FONT_CACHE_REVALIDATE_SECONDS = 60 * 60 * 24 * 30 -/** Measured off the reference cover template (`apps/sim/public/library/best-zapier-alternatives/cover.jpg`). */ -const INK_COLOR = '#525252' +/** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels. */ +const INK_COLOR = '#515151' const OG_CONTAINER_STYLE = { height: '100%', width: '100%', @@ -22,8 +25,8 @@ const OG_CONTAINER_STYLE = { flexDirection: 'column', justifyContent: 'space-between', padding: '26px', - background: '#c3c3c3', - fontFamily: 'Season', + background: '#c1c1c1', + fontFamily: 'Soehne', } satisfies CSSProperties const OG_HEADER_STYLE = { display: 'flex', @@ -34,10 +37,12 @@ const OG_HEADER_STYLE = { const OG_TITLE_STYLE = { display: 'flex', flexDirection: 'column', - fontWeight: 600, + fontWeight: 500, color: INK_COLOR, - lineHeight: 1.15, + lineHeight: 1.1, width: `${TITLE_BOX_WIDTH}px`, + /** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */ + transform: 'translateY(14px)', } satisfies CSSProperties function getTitleFontSize(title: string): number { @@ -53,6 +58,41 @@ function getTitleStyle(title: string): CSSProperties { } } +/** Sums per-character em-widths rather than counting characters, so wide CJK glyphs (docs ships `ja`/`zh`) don't under-wrap. */ +function estimateWidthEm(text: string): number { + let width = 0 + for (const char of text) { + width += CJK_RANGE.test(char) ? CJK_CHAR_WIDTH_EM : LATIN_CHAR_WIDTH_EM + } + return width +} + +/** + * Splits a single word wider than `maxWidthEm` into character-level chunks + * that each fit. CJK titles (docs ships `ja`/`zh` locales) are often + * space-free, so a whole run can arrive as one "word" from `wrapTitleLines`' + * space-based split. Breaking mid-word is correct for CJK, where each glyph + * is independently readable; Latin words never reach this path since they + * stay under `maxWidthEm` in practice. + */ +function splitOversizedWord(word: string, maxWidthEm: number): string[] { + const chunks: string[] = [] + let chunk = '' + + for (const char of word) { + const candidate = chunk + char + if (estimateWidthEm(candidate) > maxWidthEm && chunk) { + chunks.push(chunk) + chunk = char + } else { + chunk = candidate + } + } + if (chunk) chunks.push(chunk) + + return chunks +} + /** * Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`, * then joins each line with U+00A0 instead of a plain space. Satori @@ -63,14 +103,25 @@ function getTitleStyle(title: string): CSSProperties { * (which is also disabled here — lines are pre-split, not auto-wrapped). */ function wrapTitleLines(title: string, fontSize: number): string[] { - const maxCharsPerLine = Math.floor(TITLE_BOX_WIDTH / (fontSize * AVG_CHAR_WIDTH_EM)) + const maxWidthEm = TITLE_BOX_WIDTH / fontSize const words = title.split(' ') const lines: string[] = [] let current = '' for (const word of words) { + if (estimateWidthEm(word) > maxWidthEm) { + if (current) { + lines.push(current) + current = '' + } + const chunks = splitOversizedWord(word, maxWidthEm) + lines.push(...chunks.slice(0, -1)) + current = chunks[chunks.length - 1] ?? '' + continue + } + const candidate = current ? `${current} ${word}` : word - if (candidate.length > maxCharsPerLine && current) { + if (estimateWidthEm(candidate) > maxWidthEm && current) { lines.push(current) current = word } else { @@ -83,21 +134,16 @@ function wrapTitleLines(title: string, fontSize: number): string[] { } /** - * Loads a static (600/semibold) TTF instance of the site's own Season Sans - * font — the platform's real brand/body font, also used by the library/blog - * cover template this OG image matches. Instantiated from the variable font - * at `apps/docs/app/fonts/SeasonSansUprightsVF.woff2` (`fonttools - * varLib.instancer wght=600`, then flavor-stripped to plain TTF) rather than - * loading the variable WOFF2 directly: Satori (`next/og`'s renderer) can't - * parse variable fonts without excessive memory use, and can't parse WOFF2 - * at all ("Unsupported OpenType signature wOF2") — it needs an uncompressed - * TTF/OTF. Fetched over HTTP since the edge runtime has no filesystem access - * — served from `/static/fonts/` (not `/fonts/`) so it isn't intercepted by - * the site's i18n proxy (`proxy.ts`), whose matcher excludes `static` but - * not `fonts`. + * Loads Söhne Kräftig (weight 500), the typeface used on the reference cover + * template this OG image matches. Converted to a plain TTF from the + * last-shipped `soehne-kraftig.woff2` since Satori (`next/og`'s renderer) + * can't parse WOFF2 or variable fonts. Fetched over HTTP since the edge + * runtime has no filesystem access — served from `/static/fonts/` (not + * `/fonts/`) so it isn't intercepted by the site's i18n proxy (`proxy.ts`), + * whose matcher excludes `static` but not `fonts`. */ -async function loadSeasonFont(baseUrl: string): Promise { - const response = await fetch(new URL('/static/fonts/SeasonSans-600-static.ttf', baseUrl), { +async function loadTitleFont(baseUrl: string): Promise { + const response = await fetch(new URL('/static/fonts/Soehne-Kraftig.ttf', baseUrl), { next: { revalidate: FONT_CACHE_REVALIDATE_SECONDS }, }) @@ -128,7 +174,7 @@ function SimWordmark() { ) } -/** Diagonal "open" arrow, top-right — matches the library/blog cover template. */ +/** Diagonal "open" arrow, top-right — square caps and a miter join to match the reference's sharp corners. */ function CornerArrow() { return ( @@ -136,8 +182,8 @@ function CornerArrow() { d='M2 22 22 2M22 2H12M22 2V12' stroke={INK_COLOR} strokeWidth={3.6} - strokeLinecap='round' - strokeLinejoin='round' + strokeLinecap='square' + strokeLinejoin='miter' /> ) @@ -153,7 +199,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const title = searchParams.get('title') || 'Documentation' - const fontData = await loadSeasonFont(request.url) + const fontData = await loadTitleFont(request.url) const fontSize = getTitleFontSize(title) const titleLines = wrapTitleLines(title, fontSize) @@ -175,10 +221,10 @@ export async function GET(request: NextRequest) { height: 675, fonts: [ { - name: 'Season', + name: 'Soehne', data: fontData, style: 'normal', - weight: 600, + weight: 500, }, ], } diff --git a/apps/docs/app/llms.txt/route.ts b/apps/docs/app/llms.txt/route.ts index f172dd07156..ca426a7e966 100644 --- a/apps/docs/app/llms.txt/route.ts +++ b/apps/docs/app/llms.txt/route.ts @@ -44,7 +44,7 @@ Sim is the open-source AI workspace where teams build, deploy, and manage AI age ## Documentation Overview -This file provides an overview of our documentation. For full content of all pages, see ${baseUrl}/llms-full.txt +This file provides an overview of our documentation. For full content of all pages, see [llms-full.txt](${baseUrl}/llms-full.txt). ## Main Sections @@ -54,16 +54,16 @@ ${Object.entries(sections) .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') - return `### ${sectionTitle}\n\n${items.map((item) => `- ${item.title}: ${item.url}${item.description ? `\n ${item.description}` : ''}`).join('\n')}` + return `### ${sectionTitle}\n\n${items.map((item) => `- [${item.title}](${item.url})${item.description ? `\n ${item.description}` : ''}`).join('\n')}` }) .join('\n\n')} ## Additional Resources -- Full documentation content: ${baseUrl}/llms-full.txt +- [Full documentation content](${baseUrl}/llms-full.txt) - Individual page content: ${baseUrl}/llms.mdx/[page-path] -- API documentation: ${baseUrl}/api-reference/ -- Tool integrations: ${baseUrl}/tools/ +- [API documentation](${baseUrl}/api-reference/) +- [Tool integrations](${baseUrl}/tools/) ## Statistics diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt deleted file mode 100644 index aad99e364ef..00000000000 --- a/apps/docs/public/llms.txt +++ /dev/null @@ -1,51 +0,0 @@ -# Sim Documentation - -Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Create agents visually with the workflow builder, conversationally through Mothership, or programmatically with the API — connected to 1,000+ integrations and every major LLM. - -## What is Sim? - -Sim provides a complete AI workspace including: -- Mothership — natural language agent creation and workspace management -- Visual workflow builder with drag-and-drop interface -- 1,000+ built-in integrations (OpenAI, Anthropic, Slack, Gmail, GitHub, etc.) -- Knowledge bases for retrieval-augmented generation -- Built-in tables for structured data -- Real-time team collaboration -- Multiple deployment options (cloud-hosted or self-hosted) -- Custom integrations via MCP protocol - -## Main Documentation Sections - -Here are the key areas covered in our documentation: - -/introduction - Getting started with Sim AI workspace -/getting-started - Quick start guide for building your first agent -/blocks - Understanding blocks (AI agents, APIs, functions) -/tools - 1,000+ integrations and tools -/webhooks - Webhook triggers and handling -/mcp - Custom integrations via MCP protocol -/deployment - Cloud-hosted vs self-hosted deployment -/permissions - Team collaboration and workspace management -/collaboration - Real-time editing and team features -/workflows - Building agent logic with the visual builder - -## Technical Information - -- Framework: Fumadocs (Next.js-based documentation platform) -- Content: MDX files with interactive examples -- Languages: English (primary), Spanish, French, German, Japanese, Chinese -- Search: AI-powered search and assistance available - -## Complete Documentation - -For the full documentation with all pages, examples, and interactive features, visit our documentation site. We also provide machine-readable versions at /llms.txt (full content) for AI systems. - -## Additional Resources - -- GitHub repository with agent examples -- Discord community for support and discussions -- 1,000+ built-in integrations with detailed guides -- MCP protocol documentation for custom integrations -- Self-hosting guides and Docker deployment - -For the complete documentation visit https://docs.sim.ai diff --git a/apps/docs/public/static/fonts/SeasonSans-600-static.ttf b/apps/docs/public/static/fonts/SeasonSans-600-static.ttf deleted file mode 100644 index 56dcc2b803b..00000000000 Binary files a/apps/docs/public/static/fonts/SeasonSans-600-static.ttf and /dev/null differ diff --git a/apps/docs/public/static/fonts/Soehne-Kraftig.ttf b/apps/docs/public/static/fonts/Soehne-Kraftig.ttf new file mode 100644 index 00000000000..8a92c26f779 Binary files /dev/null and b/apps/docs/public/static/fonts/Soehne-Kraftig.ttf differ diff --git a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx index 3e304e9b5be..3ccc43d148f 100644 --- a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx +++ b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx @@ -96,6 +96,7 @@ export function ContentIndexPage({ sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw' className='object-cover' priority={index < 3} + fetchPriority={index === 0 ? 'high' : undefined} unoptimized /> diff --git a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx index 358ec8a1416..2e8aa3c84b5 100644 --- a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx +++ b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx @@ -52,6 +52,7 @@ export function ContentPostPage({ className='h-auto w-full' sizes='(max-width: 768px) 100vw, 450px' priority + fetchPriority='high' itemProp='image' unoptimized /> diff --git a/apps/sim/app/(landing)/components/cta/cta.tsx b/apps/sim/app/(landing)/components/cta/cta.tsx index 61fe0cb64e4..b82a6bcf7ef 100644 --- a/apps/sim/app/(landing)/components/cta/cta.tsx +++ b/apps/sim/app/(landing)/components/cta/cta.tsx @@ -1,4 +1,5 @@ import { ChipLink } from '@sim/emcn' +import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants' /** * Landing pre-footer CTA - the page's final conversion band. A tall, centered @@ -28,10 +29,10 @@ export function Cta() { Build your first agent today.
- + Get started - + Contact sales
diff --git a/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx b/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx index 7974064feb3..4e7659ee107 100644 --- a/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx +++ b/apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx @@ -17,6 +17,18 @@ import { CalloutFrame } from '@/app/(landing)/components/features/components/cal * the right AND bottom edges bleed past the media stage's clip - a zoomed-in * peek at part of the product rather than a complete miniature, scaling * proportionally with the aspect-locked stage. Decorative. + * + * `sizes` is derived directly from the section's grid math rather than + * approximated, then rounded up to the worst-case (peak render/viewport + * ratio) in each tier so the browser never under-fetches: + * `callout = 1.25 * (viewport - 2*gutter - 32px card padding - [40px gap + + * 386px fixed copy column, desktop only])`, gutter = `px-20`/`max-lg:px-8`/ + * `max-sm:px-5` from `Features`'s grid, matching `FeatureCard`'s + * `max-lg:grid-cols-1` stack. Peak ratios (verified against a static + * reproduction of this exact layout rendered at each Tailwind breakpoint): + * ~113.3% at the `max-width: 1023px` stacked tier's own upper edge, ~108.6% + * at `1460px` (the container's cap, where render width stops growing with + * viewport - hence the final tier is a flat px value, not a vw fraction). */ export function IntegrationsCallout() { return ( @@ -29,7 +41,7 @@ export function IntegrationsCallout() { src='/landing/feature-integrate-ui.png' alt='' fill - sizes='1050px' + sizes='(max-width: 1023px) 114vw, (max-width: 1460px) 109vw, 1053px' className='object-cover' /> diff --git a/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx b/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx index 93a4dee3d6e..ff392fd0b2a 100644 --- a/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx +++ b/apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx @@ -1,4 +1,5 @@ import { ChipLink, cn } from '@sim/emcn' +import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants' /** * Hero-scale sizing shared by both CTAs - one step up from the navbar's 30px @@ -24,11 +25,11 @@ const CTA_SIZE = 'h-[36px] px-[0.571em] text-[15px] [&>span]:[font-size:inherit] export function HeroCta() { return (
- + Request a demo diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx index b80b7227340..ea259f8e01b 100644 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ b/apps/sim/app/(landing)/components/hero/hero.tsx @@ -102,6 +102,7 @@ export function Hero() { alt='' fill priority + fetchPriority='high' quality={90} sizes='(max-width: 1460px) 100vw, 1300px' className='object-cover' diff --git a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx index 59e31ad04a7..7be454b18ae 100644 --- a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx @@ -10,6 +10,7 @@ import { NAVBAR_GLASS_SURFACE, useNavbarFrost, } from '@/app/(landing)/components/navbar/components/navbar-shell' +import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants' /** * Mobile navigation - the `< lg` counterpart to the desktop nav clusters. @@ -70,7 +71,7 @@ export function MobileNav({ stars }: MobileNavProps) { return (
- + Sign up
diff --git a/apps/sim/app/(landing)/constants.ts b/apps/sim/app/(landing)/constants.ts new file mode 100644 index 00000000000..c16abe98f78 --- /dev/null +++ b/apps/sim/app/(landing)/constants.ts @@ -0,0 +1,8 @@ +/** + * Shared landing-page CTA destinations. Every "Get started"/"Sign up" CTA + * across the marketing site funnels to signup; every "Talk to sales"/ + * "Contact sales" CTA funnels to the demo-request form. Centralized here so + * no CTA can drift to the wrong destination independently. + */ +export const SIGNUP_HREF = '/signup' +export const DEMO_HREF = '/demo' diff --git a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx index 4b7a499704d..4597437ae31 100644 --- a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx @@ -3,10 +3,24 @@ import { type CSSProperties, useEffect, useRef, useState } from 'react' import { chipBorderShadowRing, cn } from '@sim/emcn' import dynamic from 'next/dynamic' +import { preconnect } from 'react-dom' import { DemoForm, type DemoLead } from '@/app/(landing)/demo/components/demo-form' const importScheduler = () => import('@/app/(landing)/demo/components/demo-scheduler') +/** + * Warm the entire booking path while the visitor fills the form: preconnect to + * app.cal.com, then load the scheduler chunk, Cal.com's embed.js, and the + * booker iframe assets (via the embed's `preload` instruction). Fired on first + * form focus so nothing Cal.com-related competes with initial page load — the + * connection handshake overlaps the chunk import, and it all finishes long + * before the visitor submits. + */ +function preloadScheduler() { + preconnect('https://app.cal.com') + return importScheduler().then((m) => m.preloadCalEmbed()) +} + /** * Lazy-loaded so the Cal.com embed never enters the initial landing bundle - it * loads only once a visitor reaches the booking step. `loading: () => null` (no @@ -75,7 +89,7 @@ export function DemoBooking({ className }: DemoBookingProps) {
void importScheduler()} + onFocusCapture={() => void preloadScheduler()} >
diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx index c86d0ea9fbf..c10cc3ea103 100644 --- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx @@ -19,6 +19,30 @@ interface DemoSchedulerProps { lead: DemoLead } +let calEmbedPreloaded = false + +/** + * Warm the Cal.com embed before the scheduler mounts. Loads `embed.js` and + * issues the embed's `preload` instruction, which fetches the booker in a + * hidden `?preload=true` iframe so its assets are already cached when the real + * embed renders on submit. Without this, nothing Cal.com-related starts + * downloading until the visitor presses Continue, which is why the calendar + * used to take several seconds to appear. Idempotent — repeat calls no-op + * while a warm-up is in flight or done, but a failed embed.js load resets the + * flag so a later focus can retry. + */ +export function preloadCalEmbed(): void { + if (calEmbedPreloaded) return + calEmbedPreloaded = true + getCalApi({ namespace: CAL_NAMESPACE }) + .then((cal) => { + cal('preload', { calLink: CAL_LINK }) + }) + .catch(() => { + calEmbedPreloaded = false + }) +} + /** * Step 2 of the booking card - the Cal.com scheduler, prefilled from the form's * {@link DemoLead}. Rendered inside the card chrome owned by {@link DemoBooking} diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/index.ts b/apps/sim/app/(landing)/demo/components/demo-scheduler/index.ts index 72290c7861f..a280b21282f 100644 --- a/apps/sim/app/(landing)/demo/components/demo-scheduler/index.ts +++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/index.ts @@ -1 +1 @@ -export { DemoScheduler } from './demo-scheduler' +export { DemoScheduler, preloadCalEmbed } from './demo-scheduler' diff --git a/apps/sim/app/(landing)/enterprise/enterprise.tsx b/apps/sim/app/(landing)/enterprise/enterprise.tsx index 100ab549b0d..0396ddcacbc 100644 --- a/apps/sim/app/(landing)/enterprise/enterprise.tsx +++ b/apps/sim/app/(landing)/enterprise/enterprise.tsx @@ -9,6 +9,7 @@ import { } from '@/app/(landing)/components/solutions-page/components' import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants' import type { SolutionsPageConfig } from '@/app/(landing)/components/solutions-page/types' +import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants' import { EnterpriseFeatureGrid } from '@/app/(landing)/enterprise/components/enterprise-feature-grid' import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop' import { @@ -65,6 +66,7 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = { setParams({ billing: next ? 'annual' : 'monthly' }) const discountPct = Math.round(ANNUAL_DISCOUNT_RATE * 100) - const proPrice = isAnnual - ? Math.round(CREDIT_TIERS[0].dollars * (1 - ANNUAL_DISCOUNT_RATE)) - : CREDIT_TIERS[0].dollars - const maxPrice = isAnnual - ? Math.round(CREDIT_TIERS[1].dollars * (1 - ANNUAL_DISCOUNT_RATE)) - : CREDIT_TIERS[1].dollars + const proPrice = isAnnual ? annualPrice(CREDIT_TIERS[0].dollars) : CREDIT_TIERS[0].dollars + const maxPrice = isAnnual ? annualPrice(CREDIT_TIERS[1].dollars) : CREDIT_TIERS[1].dollars const priceSubtext = isAnnual ? 'per user/month, billed annually' : 'per user/month, billed monthly' @@ -114,7 +123,7 @@ export function PricingPlans({ heading }: PricingPlansProps) { price='$0' priceSubtext='Free forever' cta={FREE_CTA} - sections={sectionsForColumn(0)} + sections={SECTIONS_BY_COLUMN[0]} />
diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index a860df8eb28..6d32eb61f6b 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -529,6 +529,43 @@ describe('Chat OTP API Route', () => { }) }) + describe('PUT - Verify OTP (authType re-check)', () => { + beforeEach(() => { + mockGetStorageMethod.mockReturnValue('redis') + mockRedisGet.mockResolvedValue(`${mockOTP}:0`) + }) + + it('rejects verification when the chat has switched away from email auth', async () => { + mockDbSelect.mockImplementationOnce(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: mockChatId, + authType: 'password', + password: 'encrypted-password', + }, + ]), + }), + }), + })) + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'This chat does not use email authentication', + 400 + ) + expect(mockRedisGet).not.toHaveBeenCalled() + expect(mockSetChatAuthCookie).not.toHaveBeenCalled() + }) + }) + describe('PUT - Verify OTP (Database path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('database') diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index aeb1b69f450..248fcfa84ab 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -174,6 +174,10 @@ export const PUT = withRouteHandler( const deployment = deploymentResult[0] + if (deployment.authType !== 'email') { + return createErrorResponse('This chat does not use email authentication', 400) + } + const storedValue = await getOTP('chat', deployment.id, email) if (!storedValue) { return createErrorResponse('No verification code found, request a new one', 400) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index f6df57eb59d..aa810049034 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => { const mockIsUsingCloudStorage = vi.fn() const mockUploadFile = vi.fn() const mockUploadExecutionFile = vi.fn() + const mockCheckStorageQuota = vi.fn() return { mockVerifyFileAccess, @@ -35,6 +36,7 @@ const mocks = vi.hoisted(() => { mockIsUsingCloudStorage, mockUploadFile, mockUploadExecutionFile, + mockCheckStorageQuota, } }) @@ -108,6 +110,10 @@ vi.mock('@/lib/uploads/setup.server', () => ({ UPLOAD_DIR_SERVER: '/tmp/test-uploads', })) +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuota: mocks.mockCheckStorageQuota, +})) + import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { POST } from '@/app/api/files/upload/route' @@ -183,6 +189,12 @@ function setupFileApiMocks( key: 'test-key', path: '/test/path', }) + + mocks.mockCheckStorageQuota.mockResolvedValue({ + allowed: true, + currentUsage: 0, + limit: Number.MAX_SAFE_INTEGER, + }) } describe('File Upload API Route', () => { @@ -640,6 +652,125 @@ describe('File Upload Security Tests', () => { }) }) + describe('Mothership Context Permission Gate', () => { + const postMothershipUpload = async (workspaceId: string | null = 'test-workspace-id') => { + const formData = new FormData() + const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) + formData.append('file', file) + formData.append('context', 'mothership') + if (workspaceId !== null) formData.append('workspaceId', workspaceId) + + const req = new Request('http://localhost/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + return POST(req as unknown as NextRequest) + } + + beforeEach(() => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + }) + }) + + it('rejects mothership uploads without workspaceId', async () => { + const response = await postMothershipUpload(null) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.message).toContain('workspaceId') + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) + + it('rejects mothership uploads for a workspace the caller does not belong to', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) + + const response = await postMothershipUpload() + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Write or Admin access required for mothership uploads') + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) + + it('rejects mothership uploads for a read-only workspace member', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') + + const response = await postMothershipUpload() + + expect(response.status).toBe(403) + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) + + it('rejects mothership uploads over the caller storage quota', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mocks.mockCheckStorageQuota.mockResolvedValue({ + allowed: false, + currentUsage: 100, + limit: 100, + error: 'Storage limit exceeded. Used: 0.00GB, Limit: 0GB', + }) + + const response = await postMothershipUpload() + + expect(response.status).toBe(413) + const data = await response.json() + expect(data.error).toContain('Storage limit exceeded') + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + }) + + it('allows mothership uploads for a write-permission workspace member', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + + const response = await postMothershipUpload() + + expect(response.status).toBe(200) + expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( + 'test-user-id', + 'workspace', + 'test-workspace-id' + ) + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() + }) + + it('allows mothership uploads for an admin-permission workspace member', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') + + const response = await postMothershipUpload() + + expect(response.status).toBe(200) + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() + }) + + it('checks quota once against the combined size of a multi-file batch', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + + const formData = new FormData() + const fileA = new File(['a'.repeat(10)], 'a.pdf', { type: 'application/pdf' }) + const fileB = new File(['b'.repeat(20)], 'b.pdf', { type: 'application/pdf' }) + formData.append('file', fileA) + formData.append('file', fileB) + formData.append('context', 'mothership') + formData.append('workspaceId', 'test-workspace-id') + + const req = new Request('http://localhost/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + const response = await POST(req as unknown as NextRequest) + + expect(response.status).toBe(200) + expect(mocks.mockCheckStorageQuota).toHaveBeenCalledTimes(1) + expect(mocks.mockCheckStorageQuota).toHaveBeenCalledWith('test-user-id', 30) + expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledTimes(1) + }) + }) + describe('Authentication Requirements', () => { it('should reject uploads without authentication', async () => { authMockFns.mockGetSession.mockResolvedValue(null) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 64914af8970..ab934a64960 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -115,6 +115,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { executionUploadContext = { workspaceId, workflowId, executionId } } + // Mothership context requires the same workspace write/admin permission check, plus a + // storage quota check. Resolve both once per request (not per file) since workspaceId is + // invariant across all files in the upload and quota must account for the full batch size, + // not just one file. + let mothershipWorkspaceId: string | undefined + if (context === 'mothership') { + if (!workspaceId) { + throw new InvalidRequestError('Mothership context requires workspaceId parameter') + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for mothership uploads' }, + { status: 403 } + ) + } + + const { checkStorageQuota } = await import('@/lib/billing/storage') + const quotaCheck = await checkStorageQuota(session.user.id, totalFileSize) + if (!quotaCheck.allowed) { + return NextResponse.json( + { error: quotaCheck.error || 'Storage limit exceeded' }, + { status: 413 } + ) + } + + mothershipWorkspaceId = workspaceId + } + const uploadResults = [] for (const file of files) { @@ -261,21 +291,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } // Handle mothership context (chat-scoped uploads to workspace S3) - if (context === 'mothership') { - if (!workspaceId) { - throw new InvalidRequestError('Chat context requires workspaceId parameter') - } - + if (context === 'mothership' && mothershipWorkspaceId) { logger.info(`Uploading mothership file: ${originalName}`) - const storageKey = generateWorkspaceFileKey(workspaceId, originalName) + const storageKey = generateWorkspaceFileKey(mothershipWorkspaceId, originalName) const metadata: Record = { originalName: originalName, uploadedAt: new Date().toISOString(), purpose: 'mothership', userId: session.user.id, - workspaceId, + workspaceId: mothershipWorkspaceId, } const fileInfo = await storageService.uploadFile({ diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 6fe478fe4b4..3c10f68f4d4 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -5,12 +5,26 @@ import { helpFormBodySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + MAX_MULTIPART_OVERHEAD_BYTES, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getHelpEmailAddress } from '@/lib/messaging/email/utils' +import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' const logger = createLogger('HelpAPI') +/** + * The form can carry several image attachments with no server-side count + * cap, so this reuses the repo's largest existing per-request form-data + * bound (see files/upload route) rather than an arbitrary smaller limit + * that could reject a legitimate multi-image submission. + */ +const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES + export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() @@ -23,7 +37,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const email = session.user.email - const formData = await req.formData() + const formData = await readFormDataWithLimit(req, { + maxBytes: MAX_HELP_FORM_BYTES, + label: 'Help request form data', + }) const subject = formData.get('subject') as string const message = formData.get('message') as string @@ -128,6 +145,13 @@ ${message} { status: 200 } ) } catch (error) { + if (isPayloadSizeLimitError(error)) { + logger.warn(`[${requestId}] Help request form data too large`, { message: error.message }) + return NextResponse.json( + { error: `Request body exceeds the maximum allowed size of ${MAX_HELP_FORM_BYTES} bytes` }, + { status: 413 } + ) + } if (error instanceof Error && error.message.includes('not configured')) { logger.error(`[${requestId}] Email service configuration error`, error) return NextResponse.json( diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index 059abee2fb6..75d9df48173 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -7,7 +7,7 @@ import { createChunkBodySchema, listKnowledgeChunksQuerySchema, } from '@/lib/api/contracts/knowledge' -import { isZodError, parseRequest } from '@/lib/api/server' +import { isZodError, parseJsonBody, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -103,9 +103,6 @@ export const POST = withRouteHandler( const { id: knowledgeBaseId, documentId } = await params try { - const body = await req.json() - const { workflowId, ...searchParams } = body - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) @@ -113,7 +110,14 @@ export const POST = withRouteHandler( } const userId = auth.userId + const parsedBody = await parseJsonBody(req) + if (!parsedBody.success) return parsedBody.response + const { workflowId, ...searchParams } = parsedBody.data as Record + if (workflowId) { + if (typeof workflowId !== 'string') { + return NextResponse.json({ error: 'workflowId must be a string' }, { status: 400 }) + } const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId, diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 37236aa160f..fa227d69351 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { processContextsServer } from '@/lib/copilot/chat/process-contents' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, @@ -141,25 +142,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content // double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime const agentMentions = contexts as unknown as ChatContext[] | undefined - const [workspaceContext, integrationTools, userSkillTool, userPermission, agentContexts] = - await Promise.all([ - generateWorkspaceContext(workspaceId, userId), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - buildUserSkillTool(workspaceId), - getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), - processContextsServer( - agentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), - ]) + const [ + workspaceContext, + integrationTools, + userSkillTool, + userPermission, + entitlements, + agentContexts, + ] = await Promise.all([ + generateWorkspaceContext(workspaceId, userId), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildUserSkillTool(workspaceId), + getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), + computeWorkspaceEntitlements(workspaceId, userId), + processContextsServer( + agentMentions, + userId, + lastUserMessage, + workspaceId, + effectiveChatId + ).catch((error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + }), + ]) const requestPayload: Record = { messages, responseFormat, @@ -182,6 +190,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), + ...(entitlements.length > 0 ? { entitlements } : {}), } let allowExplicitAbort = true diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts index 8c00a1c7787..a555948c9d9 100644 --- a/apps/sim/app/api/speech/token/route.test.ts +++ b/apps/sim/app/api/speech/token/route.test.ts @@ -138,4 +138,13 @@ describe('POST /api/speech/token — usage attribution', () => { workspaceId: 'ws-1', }) }) + + it('rejects an oversized body before any auth/billing work runs', async () => { + const oversizedBody = { chatId: 'x'.repeat(64 * 1024) } + const res = await POST(createMockRequest('POST', oversizedBody)) + + expect(res.status).toBe(413) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockRecordUsage).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/speech/token/route.ts b/apps/sim/app/api/speech/token/route.ts index 3ccb721366f..47a63b741dc 100644 --- a/apps/sim/app/api/speech/token/route.ts +++ b/apps/sim/app/api/speech/token/route.ts @@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { speechTokenBodySchema } from '@/lib/api/contracts/media/speech' +import { parseOptionalJsonBody } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' @@ -34,6 +35,13 @@ const STT_TOKEN_RATE_LIMIT = { refillIntervalMs: 72 * 1000, } as const +/** + * This body only ever carries an optional chatId/workspaceId string, so a + * tight cap keeps an unauthenticated caller from forcing a large in-memory + * allocation before the auth checks below run. + */ +const MAX_SPEECH_TOKEN_BODY_BYTES = 16 * 1024 + function hashVoiceToken(token: string): string { return createHash('sha256').update(token).digest('hex') } @@ -87,8 +95,9 @@ async function validateChatAuth( export const POST = withRouteHandler(async (request: NextRequest) => { try { - const rawBody = await request.json().catch(() => ({})) - const body = speechTokenBodySchema.safeParse(rawBody) + const parsedBody = await parseOptionalJsonBody(request, MAX_SPEECH_TOKEN_BODY_BYTES) + if (!parsedBody.success) return parsedBody.response + const body = speechTokenBodySchema.safeParse(parsedBody.data ?? {}) const chatId = body.success && typeof body.data.chatId === 'string' ? body.data.chatId : undefined diff --git a/apps/sim/app/llms-full.txt/route.ts b/apps/sim/app/llms-full.txt/route.ts index b198f56aa6f..22718fdd2b1 100644 --- a/apps/sim/app/llms-full.txt/route.ts +++ b/apps/sim/app/llms-full.txt/route.ts @@ -9,7 +9,7 @@ export function GET() { ## Overview -Sim is the AI workspace where teams create agents visually with the workflow builder, conversationally through Mothership, or programmatically with the API. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that automate real work across systems, and manage them with full observability. SOC2 compliant. +Sim is the AI workspace where teams create agents visually with the workflow builder, conversationally through Chat, or programmatically with the API. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that automate real work across systems, and manage them with full observability. SOC2 compliant. ## Product Details @@ -122,7 +122,7 @@ Built-in table creation and management: ## Technical Architecture ### Frontend -- Next.js 15 with App Router +- Next.js 16 with App Router - React Flow for the visual builder - Tailwind CSS for styling - Zustand for state management @@ -150,26 +150,26 @@ Built-in table creation and management: ## Links -- **Website**: ${baseUrl} -- **Documentation**: https://docs.sim.ai -- **API Reference**: https://docs.sim.ai/api -- **GitHub**: https://github.com/simstudioai/sim -- **Discord**: https://discord.gg/Hr4UWYEcTT -- **X/Twitter**: https://x.com/simdotai -- **LinkedIn**: https://linkedin.com/company/simstudioai +- [Website](${baseUrl}): Product overview and primary entry point +- [Documentation](https://docs.sim.ai): Product guides and technical reference +- [API Reference](https://docs.sim.ai/api): API documentation +- [GitHub](https://github.com/simstudioai/sim): Open-source codebase +- [Discord](https://discord.gg/Hr4UWYEcTT): Community server +- [X/Twitter](https://x.com/simdotai): Announcements and updates +- [LinkedIn](https://linkedin.com/company/simstudioai): Company page ## Support -- **Email**: help@sim.ai -- **Security Issues**: security@sim.ai -- **Documentation**: https://docs.sim.ai -- **Community Discord**: https://discord.gg/Hr4UWYEcTT +- [Documentation](https://docs.sim.ai): Self-serve guides and reference +- [Community Discord](https://discord.gg/Hr4UWYEcTT): Community support +- Email: help@sim.ai +- Security issues: security@sim.ai ## Legal -- **Terms of Service**: ${baseUrl}/terms -- **Privacy Policy**: ${baseUrl}/privacy -- **Security**: ${baseUrl}/.well-known/security.txt +- [Terms of Service](${baseUrl}/terms): Legal terms +- [Privacy Policy](${baseUrl}/privacy): Data handling practices +- [Security](${baseUrl}/.well-known/security.txt): Vulnerability disclosure policy ` return new Response(llmsFullContent, { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index 6f1e8f75df0..3ff4df51c2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared' import { PreviewToolbar } from './preview-toolbar' @@ -209,6 +210,7 @@ export const DocxPreview = memo(function DocxPreview({ ignoreHeight: false, }) if (!cancelled && containerRef.current) { + sanitizeRenderedHyperlinks(containerRef.current) applyPostRenderStyling() setHasRenderedPreview(true) setDocumentRenderVersion((version) => version + 1) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx index e4ba8033261..d65fd1d928b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx @@ -1,12 +1,14 @@ 'use client' -import { memo } from 'react' +import { memo, useState } from 'react' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useFileContentSource } from '@/hooks/use-file-content-source' +import { PREVIEW_LOADING_OVERLAY } from './preview-shared' import { ZoomablePreview } from './zoomable-preview' export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) { const source = useFileContentSource() + const [hasSettled, setHasSettled] = useState(false) // Version the URL on updatedAt: overwrites keep the same storage key, so an unversioned // URL would resolve to a previously cached copy instead of the rewritten bytes. const serveUrl = source.buildUrl(file.key, { @@ -14,14 +16,19 @@ export const ImagePreview = memo(function ImagePreview({ file }: { file: Workspa }) return ( - - {file.name} - +
+ + {file.name} setHasSettled(true)} + onError={() => setHasSettled(true)} + /> + + {!hasSettled && PREVIEW_LOADING_OVERLAY} +
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx index 4796ae6bb1e..041a0225074 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx @@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' import { cn } from '@sim/emcn' +import { Loader } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' const logger = createLogger('FilePreview') @@ -87,13 +88,20 @@ export function resolvePreviewError( return renderError } +/** Canonical content-area loading spinner, matching the rest of the app. */ +const PREVIEW_LOADING_SPINNER = ( + +) + /** - * Canonical blank loading overlay for previews that render into a - * `--surface-1` canvas. Absolutely covers the canvas (with `z-10` so it - * paints above in-flow render targets) until the preview is ready. + * Canonical loading overlay for previews that render into a `--surface-1` + * canvas. Absolutely covers the canvas (with `z-10` so it paints above + * in-flow render targets) with a centered spinner until the preview is ready. */ export const PREVIEW_LOADING_OVERLAY = ( -
+
+ {PREVIEW_LOADING_SPINNER} +
) interface PreviewLoadingFrameProps { @@ -104,14 +112,21 @@ interface PreviewLoadingFrameProps { } /** - * Canonical in-flow blank loading frame shown while a preview is fetching or - * rendering. The `tone` must match the background of the loaded state it is - * standing in for, so mount completion does not flash a different token. + * Canonical in-flow loading frame with a centered spinner, shown while a + * preview is fetching or rendering. The `tone` must match the background of + * the loaded state it is standing in for, so mount completion does not flash + * a different token. */ export function PreviewLoadingFrame({ className, tone = 'bg' }: PreviewLoadingFrameProps) { return (
+ className={cn( + 'flex items-center justify-center', + tone === 'surface' ? 'bg-[var(--surface-1)]' : 'bg-[var(--bg)]', + className + )} + > + {PREVIEW_LOADING_SPINNER} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index c17577c917b..7db9b1481b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -15,6 +15,7 @@ import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react' import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram' import { detectLanguage } from './detect-language' +import { useEditorEditable } from './use-editor-editable' const PLAIN = 'plain' const MERMAID = 'mermaid' @@ -59,6 +60,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView const [editingInline, setEditingInline] = useState(false) const [peekSource, setPeekSource] = useState(false) const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) + const editable = useEditorEditable(editor) const explicitLanguage = node.attrs.language as string | null const text = node.textContent @@ -68,7 +70,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView // diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by // focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret. useEffect(() => { - if (!isMermaid || !editor.isEditable) { + if (!isMermaid || !editable) { setEditingInline(false) return } @@ -91,9 +93,9 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView editor.off('focus', sync) editor.off('blur', sync) } - }, [editor, getPos, isMermaid]) + }, [editor, getPos, isMermaid, editable]) - const showSource = editor.isEditable ? editingInline : peekSource + const showSource = editable ? editingInline : peekSource const showDiagram = isMermaid && text.trim().length > 0 && !showSource // Skip language detection on the mermaid path — the picker/label never render there. @@ -104,7 +106,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView 'Plain text' const toggleSource = () => { - if (!editor.isEditable) { + if (!editable) { setPeekSource((value) => !value) return } @@ -144,7 +146,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView )} {!isMermaid && - (editor.isEditable ? ( + (editable ? ( // Editable: a language picker. Read-only: a static label — selecting a language calls // updateAttributes, which would mutate a doc that must not change. @@ -179,7 +181,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView {label} ))} - {!isMermaid && editor.isEditable && ( + {!isMermaid && editable && ( +
+ ))} +
+ )} +
+ ) + } + return (
{ if (!isPreview) { collaborativeSetSubblockValue(blockId, subBlock.id, value) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 4ab924e8f3d..4f9da917a1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -95,6 +95,7 @@ import { useCanvasModeStore } from '@/stores/canvas-mode' import { useChatStore } from '@/stores/chat/store' import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution' import { useSearchModalStore } from '@/stores/modals/search/store' +import type { PendingConnect } from '@/stores/modals/search/types' import { usePanelEditorStore } from '@/stores/panel' import { useUndoRedoStore } from '@/stores/undo-redo' import { useVariablesModalStore } from '@/stores/variables/modal' @@ -209,6 +210,7 @@ interface AddBlockFromToolbarDetail { type?: unknown enableTriggerMode?: unknown presetOperation?: unknown + pendingConnect?: PendingConnect } /** @@ -1779,9 +1781,55 @@ const WorkflowContent = React.memo( * @param position - Drop position in ReactFlow coordinates. */ const handleToolbarDrop = useCallback( - (data: { type: string; enableTriggerMode?: boolean }, position: { x: number; y: number }) => { + ( + data: { + type: string + enableTriggerMode?: boolean + presetOperation?: string + forcedSource?: { nodeId: string; handleId: string } + }, + position: { x: number; y: number } + ) => { if (!data.type || data.type === 'connectionBlock') return + const operationConfig = data.presetOperation + ? { operation: data.presetOperation } + : undefined + + const { forcedSource } = data + + /** + * Edge for the new block. With a `forcedSource` (a drag-release from a + * handle), wire from that exact handle — but only when it stays within the + * resolved container context, matching onConnect's boundary rules; a + * cross-boundary source yields no edge. Otherwise fall back to normal + * proximity auto-connect. + */ + const resolveEdge = ( + targetId: string, + targetParentId: string | null, + fallback: () => Edge | undefined + ): Edge | undefined => { + if (!forcedSource) return fallback() + + const isContainerStartHandle = + forcedSource.handleId === 'loop-start-source' || + forcedSource.handleId === 'parallel-start-source' + if (isContainerStartHandle) { + // A container-start handle may only wire to a child of that container. + return forcedSource.nodeId === targetParentId + ? createEdgeObject(forcedSource.nodeId, targetId, forcedSource.handleId) + : undefined + } + + const sourceBlock = blocks[forcedSource.nodeId] + if (!sourceBlock) return undefined + const sourceParentId = sourceBlock.data?.parentId ?? null + return sourceParentId === targetParentId + ? createEdgeObject(forcedSource.nodeId, targetId, forcedSource.handleId) + : undefined + } + try { const containerInfo = isPointInLoopNode(position) @@ -1811,11 +1859,13 @@ const WorkflowContent = React.memo( .filter((b) => b.data?.parentId === containerInfo.loopId) .map((b) => ({ id: b.id, type: b.type, position: b.position })) - const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { - targetParentId: containerInfo.loopId, - existingChildBlocks, - containerId: containerInfo.loopId, - }) + const autoConnectEdge = resolveEdge(id, containerInfo.loopId, () => + tryCreateAutoConnectEdge(relativePosition, id, { + targetParentId: containerInfo.loopId, + existingChildBlocks, + containerId: containerInfo.loopId, + }) + ) addBlock( id, @@ -1836,9 +1886,11 @@ const WorkflowContent = React.memo( resizeLoopNodesWrapper() } else { - const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { - targetParentId: null, - }) + const autoConnectEdge = resolveEdge(id, null, () => + tryCreateAutoConnectEdge(position, id, { + targetParentId: null, + }) + ) addBlock( id, @@ -1903,11 +1955,13 @@ const WorkflowContent = React.memo( .filter((b) => b.data?.parentId === containerInfo.loopId) .map((b) => ({ id: b.id, type: b.type, position: b.position })) - const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, { - targetParentId: containerInfo.loopId, - existingChildBlocks, - containerId: containerInfo.loopId, - }) + const autoConnectEdge = resolveEdge(id, containerInfo.loopId, () => + tryCreateAutoConnectEdge(relativePosition, id, { + targetParentId: containerInfo.loopId, + existingChildBlocks, + containerId: containerInfo.loopId, + }) + ) // Add block with parent info AND autoConnectEdge (atomic operation) addBlock( @@ -1921,7 +1975,9 @@ const WorkflowContent = React.memo( }, containerInfo.loopId, 'parent', - autoConnectEdge + autoConnectEdge, + undefined, + operationConfig ) // Resize the container node to fit the new block @@ -1931,9 +1987,11 @@ const WorkflowContent = React.memo( // Centralized trigger constraints if (checkTriggerConstraints(data.type)) return - const autoConnectEdge = tryCreateAutoConnectEdge(position, id, { - targetParentId: null, - }) + const autoConnectEdge = resolveEdge(id, null, () => + tryCreateAutoConnectEdge(position, id, { + targetParentId: null, + }) + ) // Regular canvas drop with auto-connect edge // Use enableTriggerMode from drag data if present (when dragging from Triggers tab) @@ -1947,7 +2005,8 @@ const WorkflowContent = React.memo( undefined, undefined, autoConnectEdge, - enableTriggerMode + enableTriggerMode, + operationConfig ) } } catch (err) { @@ -1961,6 +2020,7 @@ const WorkflowContent = React.memo( addBlock, tryCreateAutoConnectEdge, checkTriggerConstraints, + createEdgeObject, ] ) @@ -1972,11 +2032,30 @@ const WorkflowContent = React.memo( return } - const { type, enableTriggerMode, presetOperation } = event.detail + const { type, enableTriggerMode, presetOperation, pendingConnect } = event.detail if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return + // Complete an edge drag-release: only a genuine palette selection carries + // `pendingConnect` (other add-block dispatchers — toolbar, sidebar, command + // list — don't), so its presence is the signal. Delegating to + // handleToolbarDrop with the drag source gives container-aware placement AND + // an edge from the released handle that respects container boundaries. + if (pendingConnect) { + // screenToFlowPosition subtracts the pane rect internally — pass raw client coords. + handleToolbarDrop( + { + type, + enableTriggerMode: enableTriggerMode === true, + presetOperation: typeof presetOperation === 'string' ? presetOperation : undefined, + forcedSource: pendingConnect.source, + }, + screenToFlowPosition({ x: pendingConnect.screenX, y: pendingConnect.screenY }) + ) + return + } + const basePosition = getViewportCenter() if (type === 'loop' || type === 'parallel') { @@ -2054,6 +2133,8 @@ const WorkflowContent = React.memo( effectivePermissions.canEdit, checkTriggerConstraints, tryCreateAutoConnectEdge, + screenToFlowPosition, + handleToolbarDrop, ]) /** @@ -3132,6 +3213,17 @@ const WorkflowContent = React.memo( target: targetNode.id, targetHandle: 'target', }) + } else if (!targetNode) { + // Released on empty canvas: open the command palette with the drag origin + // + drop point, so the chosen block lands here wired from this handle. + useSearchModalStore.getState().open({ + sections: ['blocks', 'tools', 'toolOperations'], + pendingConnect: { + source: { nodeId: source.nodeId, handleId: source.handleId }, + screenX: clientPos.clientX, + screenY: clientPos.clientY, + }, + }) } connectionSourceRef.current = null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 1c5b4fd32bc..beac3a100cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -40,6 +40,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store' import type { SearchBlockItem, SearchDocItem, + SearchSection, SearchToolOperationItem, } from '@/stores/modals/search/types' import { @@ -69,7 +70,7 @@ import type { WorkflowItem, WorkspaceItem, } from './utils' -import { filterAndSort } from './utils' +import { filterAndCap, filterAndSort } from './utils' const logger = createLogger('SearchModal') @@ -118,6 +119,9 @@ export function SearchModal({ (state) => state.data ) + const sections = useSearchModalStore((state) => state.sections) + const showSection = (key: SearchSection) => !sections || sections.includes(key) + const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) }, []) @@ -347,6 +351,7 @@ export function SearchModal({ detail: { type: block.type, enableTriggerMode, + pendingConnect: useSearchModalStore.getState().pendingConnect, }, }) ) @@ -364,7 +369,11 @@ export function SearchModal({ (op: SearchToolOperationItem) => { window.dispatchEvent( new CustomEvent('add-block-from-toolbar', { - detail: { type: op.blockType, presetOperation: op.operationId }, + detail: { + type: op.blockType, + presetOperation: op.operationId, + pendingConnect: useSearchModalStore.getState().pendingConnect, + }, }) ) captureEvent(posthogRef.current, 'search_result_selected', { @@ -566,60 +575,69 @@ export function SearchModal({ }, [actions, isOnWorkflowPage, isOnIntegrationsPage, deferredSearch]) /** - * Ranking matches against clean, human-meaningful text only (names, types, - * aliases, folder paths) — never the structural `-`/uuid tokens used - * for cmdk row identity. Those tokens carry letters (e.g. "block", "tool") that - * would otherwise let short fuzzy queries scatter-match unrelated items. + * Blocks and tools rank by name first, with `searchValue` (type + option + * labels) as a lower-tier fallback, so an exact name match wins while a block + * stays findable by an option label. */ const filteredBlocks = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(blocks, (b) => b.searchValue ?? b.name, deferredSearch) + return filterAndCap( + blocks, + (b) => b.name, + deferredSearch, + (b) => b.searchValue + ) }, [isOnWorkflowPage, blocks, deferredSearch]) const filteredTools = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(tools, (t) => t.searchValue ?? t.name, deferredSearch) + return filterAndCap( + tools, + (t) => t.name, + deferredSearch, + (t) => t.searchValue + ) }, [isOnWorkflowPage, tools, deferredSearch]) const filteredTriggers = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(triggers, (t) => `${t.name} ${t.id}`, deferredSearch) + return filterAndCap(triggers, (t) => `${t.name} ${t.id}`, deferredSearch) }, [isOnWorkflowPage, triggers, deferredSearch]) const filteredToolOps = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(toolOperations, (op) => op.searchValue, deferredSearch) + return filterAndCap(toolOperations, (op) => op.searchValue, deferredSearch) }, [isOnWorkflowPage, toolOperations, deferredSearch]) const filteredDocs = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(docs, (d) => `${d.name} docs documentation`, deferredSearch) + return filterAndCap(docs, (d) => `${d.name} docs documentation`, deferredSearch) }, [isOnWorkflowPage, docs, deferredSearch]) const filteredTables = useMemo( - () => filterAndSort(tables, (t) => t.name, deferredSearch), + () => filterAndCap(tables, (t) => t.name, deferredSearch), [tables, deferredSearch] ) const filteredFiles = useMemo( - () => filterAndSort(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch), + () => filterAndCap(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch), [files, deferredSearch] ) const filteredKnowledgeBases = useMemo( - () => filterAndSort(knowledgeBases, (kb) => kb.name, deferredSearch), + () => filterAndCap(knowledgeBases, (kb) => kb.name, deferredSearch), [knowledgeBases, deferredSearch] ) const filteredWorkflows = useMemo( () => - filterAndSort(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch), + filterAndCap(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch), [workflows, deferredSearch] ) const filteredChats = useMemo( - () => filterAndSort(chats, (t) => t.name, deferredSearch), + () => filterAndCap(chats, (t) => t.name, deferredSearch), [chats, deferredSearch] ) const filteredWorkspaces = useMemo( - () => filterAndSort(workspaces, (w) => w.name, deferredSearch), + () => filterAndCap(workspaces, (w) => w.name, deferredSearch), [workspaces, deferredSearch] ) const filteredPages = useMemo( @@ -630,13 +648,13 @@ export function SearchModal({ /** Connected accounts: visible on the integrations page even with empty input. */ const filteredConnectedAccounts = useMemo(() => { if (!isOnIntegrationsPage) return [] - return filterAndSort(connectedAccounts, (a) => a.name, deferredSearch) + return filterAndCap(connectedAccounts, (a) => a.name, deferredSearch) }, [isOnIntegrationsPage, connectedAccounts, deferredSearch]) /** Catalog integrations: only shown once the user has typed something. */ const filteredIntegrations = useMemo(() => { - if (!isOnIntegrationsPage || !deferredSearch) return [] - return filterAndSort(integrations, (i) => i.name, deferredSearch) + if (!isOnIntegrationsPage || !deferredSearch.trim()) return [] + return filterAndCap(integrations, (i) => i.name, deferredSearch) }, [isOnIntegrationsPage, deferredSearch, integrations]) if (!mounted) return null @@ -690,77 +708,107 @@ export function SearchModal({ No results found. - - - - - - - - - - - - - - - + {showSection('actions') && ( + + )} + {showSection('connectedAccounts') && ( + + )} + {showSection('integrations') && ( + + )} + {showSection('blocks') && ( + + )} + {showSection('tools') && ( + + )} + {showSection('triggers') && ( + + )} + {showSection('chats') && ( + + )} + {showSection('workflows') && ( + + )} + {showSection('tables') && ( + + )} + {showSection('files') && ( + + )} + {showSection('knowledgeBases') && ( + + )} + {showSection('toolOperations') && ( + + )} + {showSection('workspaces') && ( + + )} + {showSection('docs') && ( + + )} + {showSection('pages') && ( + + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index eb9175abd28..a82b79fd5f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { filterAndSort, fuzzyMatch } from './utils' +import { filterAndCap, filterAndSort, fuzzyMatch, MAX_RESULTS_PER_GROUP } from './utils' /** * The matcher that shipped before fuzzy matching was introduced. Re-implemented @@ -241,3 +241,77 @@ describe('fuzzyMatch — positions for highlighting', () => { expect(result.matched).toBe(true) }) }) + +describe('filterAndSort — name ranked above secondary text', () => { + interface Item { + name: string + searchValue: string + } + const toName = (i: Item) => i.name + const toExtra = (i: Item) => i.searchValue + + it('ranks an exact name match above a substring buried in another item’s option text', () => { + const items: Item[] = [ + // Matches "agent" only inside a long secondary string (its model catalog). + { name: 'Pi Coding Agent', searchValue: `Pi Coding Agent pi ${'model-x '.repeat(60)}` }, + // Exact name match, but an even longer secondary string. + { name: 'Agent', searchValue: `Agent agent ${'claude-sonnet gpt-4o '.repeat(60)}` }, + ] + const sorted = filterAndSort(items, toName, 'agent', toExtra) + expect(sorted[0].name).toBe('Agent') + }) + + it('keeps every name match above every secondary-only match', () => { + const items: Item[] = [ + { name: 'Zeta', searchValue: 'Zeta agent agent agent' }, // strong secondary hit, no name hit + { name: 'Agent', searchValue: 'Agent agent' }, // name hit + ] + const sorted = filterAndSort(items, toName, 'agent', toExtra) + expect(sorted[0].name).toBe('Agent') + }) + + it('still surfaces an item matched only by its secondary text', () => { + const items: Item[] = [{ name: 'Agent', searchValue: 'Agent agent claude-sonnet gpt-4o' }] + expect(filterAndSort(items, toName, 'gpt-4o', toExtra)).toHaveLength(1) + }) + + it('is byte-identical to single-field ranking when no secondary accessor is given', () => { + const items = ['Slack message', 'Send message to Slack'] + expect(filterAndSort(items, (s) => s, 'slack')).toEqual( + filterAndSort(items, (s) => s, 'slack', undefined) + ) + }) +}) + +describe('filterAndCap', () => { + const id = (s: string) => s + + it('caps an active search to MAX_RESULTS_PER_GROUP', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`) + expect(filterAndCap(items, id, 'item')).toHaveLength(MAX_RESULTS_PER_GROUP) + }) + + it('never caps the empty (browse) state, even above the cap', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`) + const result = filterAndCap(items, id, '') + expect(result).toHaveLength(items.length) + expect(result).toBe(items) + }) + + it('treats whitespace-only input as browse: unfiltered and uncapped', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`) + const result = filterAndCap(items, id, ' ') + expect(result).toBe(items) + }) + + it('returns every match untrimmed when under the cap', () => { + const items = ['Slack', 'Slate', 'Slalom'] + expect(filterAndCap(items, id, 'sl')).toHaveLength(3) + }) + + it('caps to the top-ranked matches, preserving filterAndSort order', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 5 }, (_, i) => `item ${i}`) + const capped = filterAndCap(items, id, 'item') + expect(capped).toEqual(filterAndSort(items, id, 'item').slice(0, MAX_RESULTS_PER_GROUP)) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index 7970294e93f..a9f3fe9dbbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -243,17 +243,65 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult { return tokenFallback(lowerText, lowerQuery) } +/** Rank offset that lifts every name match above any secondary-text match. */ +const NAME_MATCH_TIER = 1_000_000 + /** - * Filters items whose value fuzzy-matches the search, ordered by descending - * score. Returns the input untouched when the search is empty. + * Ranks an item by its name first, falling back to secondary text (ids, aliases, + * option labels) only when the name doesn't match — a name match always wins, so + * an exact name hit isn't diluted by a long secondary string ("Agent" beats + * "Pi Coding Agent" for the query "agent"). */ -export function filterAndSort(items: T[], toValue: (item: T) => string, search: string): T[] { - if (!search) return items +function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult { + const byName = fuzzyMatch(name, search) + if (!extra) return byName + if (byName.matched) { + return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions } + } + const byExtra = fuzzyMatch(extra, search) + return byExtra.matched ? byExtra : NO_MATCH +} + +/** + * Filters and ranks items by fuzzy match, highest score first; returns the input + * unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank + * the name first and fall back to secondary text. + */ +export function filterAndSort( + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): T[] { + const query = search.trim() + if (!query) return items const scored: Array<{ item: T; score: number }> = [] for (const item of items) { - const { matched, score } = fuzzyMatch(toValue(item), search) + const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query) if (matched) scored.push({ item, score }) } scored.sort((a, b) => b.score - a.score) return scored.map((entry) => entry.item) } + +/** + * Max rows rendered per group while searching. Re-rendering an unbounded, + * reshuffling match set every keystroke is what stalls typing; results are + * score-sorted, so the cap only drops the low-relevance tail. + */ +export const MAX_RESULTS_PER_GROUP = 50 + +/** + * {@link filterAndSort} bounded to {@link MAX_RESULTS_PER_GROUP} while searching, + * so the per-keystroke render can't block typing. The empty browse state is + * returned in full. + */ +export function filterAndCap( + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): T[] { + const results = filterAndSort(items, toValue, search, toExtra) + return search.trim() ? results.slice(0, MAX_RESULTS_PER_GROUP) : results +} diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index 086617b2615..b7d8da6fa61 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -1044,7 +1044,10 @@ export const BrexBlock: BlockConfig = { routingNumber: { type: 'string', description: 'Bank routing number of the cash account' }, primary: { type: 'boolean', description: 'Whether the cash account is primary' }, accountId: { type: 'string', description: 'Account ID of the budget or spend limit' }, - description: { type: 'string', description: 'Description of the budget or spend limit' }, + description: { + type: 'string', + description: 'Description of the budget, spend limit, or transfer', + }, parentBudgetId: { type: 'string', description: 'Parent budget ID' }, ownerUserIds: { type: 'json', description: 'Owner user IDs of the budget or spend limit' }, memberUserIds: { type: 'json', description: 'Member user IDs of the spend limit' }, @@ -1052,7 +1055,7 @@ export const BrexBlock: BlockConfig = { spendType: { type: 'string', description: 'Spend type of the spend limit' }, startDate: { type: 'string', description: 'Start date of the budget or spend limit' }, endDate: { type: 'string', description: 'End date of the budget or spend limit' }, - amount: { type: 'json', description: 'Amount of the budget' }, + amount: { type: 'json', description: 'Amount of the budget or transfer' }, spendBudgetStatus: { type: 'string', description: 'Status of the budget' }, limitType: { type: 'string', description: 'Limit type of the budget' }, currentPeriodBalance: { diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts index 4ee1b9a7f0c..40ccab62097 100644 --- a/apps/sim/components/ui/index.ts +++ b/apps/sim/components/ui/index.ts @@ -14,4 +14,5 @@ export { SelectTrigger, SelectValue, } from './select' +export { ShimmerText } from './shimmer-text' export { ThinkingLoader, type ThinkingLoaderVariant } from './thinking-loader' diff --git a/apps/sim/components/ui/shimmer-text.module.css b/apps/sim/components/ui/shimmer-text.module.css new file mode 100644 index 00000000000..c4db09a974e --- /dev/null +++ b/apps/sim/components/ui/shimmer-text.module.css @@ -0,0 +1,44 @@ +/** + * Claude-style text shimmer: the text paints from a gradient with a light band + * that sweeps across the glyphs via background-clip. This is the single source + * of truth for the treatment — the ThinkingLoader label composes it. Under + * reduced motion the sweep is replaced by a gentle opacity pulse in solid ink: + * the shimmer conveys essential in-progress state, so it needs a vestibular-safe + * fallback rather than none. Consumers whose resting text is not body ink set + * `--shimmer-rest` to their resting color. + */ +.shimmer { + background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmer-sweep 2.2s linear infinite; +} + +:global(.dark) .shimmer { + background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%); +} + +@keyframes shimmer-sweep { + to { + background-position: 200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .shimmer, + :global(.dark) .shimmer { + background-image: none; + -webkit-background-clip: initial; + background-clip: initial; + color: var(--shimmer-rest, var(--text-body)); + animation: shimmer-pulse 2.2s ease-in-out infinite; + } +} + +@keyframes shimmer-pulse { + 50% { + opacity: 0.55; + } +} diff --git a/apps/sim/components/ui/shimmer-text.tsx b/apps/sim/components/ui/shimmer-text.tsx new file mode 100644 index 00000000000..6e3f7fea8f6 --- /dev/null +++ b/apps/sim/components/ui/shimmer-text.tsx @@ -0,0 +1,17 @@ +import { cn } from '@sim/emcn' +import styles from '@/components/ui/shimmer-text.module.css' + +interface ShimmerTextProps { + children: React.ReactNode + className?: string +} + +/** + * Sweeping-highlight shimmer over a text phrase — the same treatment as the + * ThinkingLoader's "Thinking…" label, reusable on any active/streaming row. + * Size and weight come from the consumer's className; the gradient replaces + * the text color, so color classes are ignored while shimmering. + */ +export function ShimmerText({ children, className }: ShimmerTextProps) { + return {children} +} diff --git a/apps/sim/components/ui/thinking-loader.module.css b/apps/sim/components/ui/thinking-loader.module.css index 83a8451d5f0..0e914eae3cb 100644 --- a/apps/sim/components/ui/thinking-loader.module.css +++ b/apps/sim/components/ui/thinking-loader.module.css @@ -92,23 +92,14 @@ white-space: nowrap; } -/* Claude-style shimmer: the text paints from a gradient with a light band - that sweeps across the glyphs via background-clip. */ +/* The sweeping-band treatment itself (gradient, timing, dark mode, reduced + motion) is owned by the shared shimmer-text module; this class only adds the + loader-scaled font sizing. Canonical normal weight per emcn rules: body text + is 400, never medium. */ .label { + composes: shimmer from "./shimmer-text.module.css"; font-size: var(--tl-label-size, 14px); - /* Canonical normal weight (per emcn rules: body text is 400, never medium). - Reads light and clean under the shimmer gradient on light surfaces. */ font-weight: 400; - background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%); - background-size: 200% 100%; - -webkit-background-clip: text; - background-clip: text; - color: transparent; - animation: label-shimmer 2.2s linear infinite; -} - -:global(.dark) .label { - background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%); } /* Static label (shimmer off): the phrase in solid body ink, no gradient sweep. */ @@ -118,12 +109,6 @@ color: var(--text-body); } -@keyframes label-shimmer { - to { - background-position: 200% 0; - } -} - /* Phrase crossfade — the incoming phrase rises and fades in while the outgoing one rises and fades out (stacked over it), so phrases rotate smoothly. */ .labelStack { @@ -366,7 +351,6 @@ @media (prefers-reduced-motion: reduce) { .frame *, - .label, .labelIn { animation: none; } diff --git a/apps/sim/content/blog/mothership/index.mdx b/apps/sim/content/blog/mothership/index.mdx index cc7d8f28eb8..8f9b0181972 100644 --- a/apps/sim/content/blog/mothership/index.mdx +++ b/apps/sim/content/blog/mothership/index.mdx @@ -8,7 +8,7 @@ authors: - emir readingTime: 10 tags: [Release, Mothership, Tables, Knowledge Base, Connectors, RAG, Sim] -ogImage: /blog/mothership/cover.png +ogImage: /blog/mothership/cover.jpg ogAlt: 'Introducing Mothership airship illustration' about: ['AI Agents', 'Workflow Automation', 'Developer Tools'] timeRequired: PT10M diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index 17b407cc0ad..17c45a6d6ef 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -134,6 +134,21 @@ describe('resolvePiModelKey', () => { expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() }) + it('cloud mode falls back to a stored workspace key for xAI', async () => { + mockGetProviderFromModel.mockReturnValue('xai') + mockGetBYOKKey.mockResolvedValue({ apiKey: 'xai-workspace-key', isBYOK: true }) + + const result = await resolvePiModelKey({ + model: 'grok-4.5', + mode: 'cloud', + workspaceId: 'ws-1', + }) + + expect(result).toEqual({ providerId: 'xai', apiKey: 'xai-workspace-key', isBYOK: true }) + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'xai') + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + it('cloud mode rejects when no user key is available (never a hosted key)', async () => { mockGetProviderFromModel.mockReturnValue('anthropic') mockGetBYOKKey.mockResolvedValue(null) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index 9d85eb8a4ee..cc28d6de4b4 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -33,7 +33,13 @@ interface ResolvePiModelKeyParams { } /** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */ -const WORKSPACE_BYOK_PROVIDERS = new Set(['anthropic', 'openai', 'google', 'mistral']) +const WORKSPACE_BYOK_PROVIDERS = new Set([ + 'anthropic', + 'openai', + 'google', + 'mistral', + 'xai', +]) /** Resolves the provider and a usable API key for the selected model. */ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise { diff --git a/apps/sim/hooks/queries/workspace-files.test.tsx b/apps/sim/hooks/queries/workspace-files.test.tsx new file mode 100644 index 00000000000..db51e9fc452 --- /dev/null +++ b/apps/sim/hooks/queries/workspace-files.test.tsx @@ -0,0 +1,104 @@ +/** + * @vitest-environment jsdom + * + * `useWorkspaceFileContent` against REAL react-query (no module mocks): the `refetchInterval` + * option must reach the query — the editor's post-stream reconcile depends on it to poll until the + * server content advances (see `use-editable-file-content.ts`), and both its consumers' test + * setups replace this module, so without this file the passthrough itself would be exercised by + * nothing but the type-checker. + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files' + +let fetchCount = 0 + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + fetchCount = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + fetchCount += 1 + return new Response('# content', { status: 200 }) + }) + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function renderContentHook(options?: { + refetchInterval?: number | false | (() => number | false) +}): { unmount: () => void } { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root: Root = createRoot(container) + + function Probe() { + useWorkspaceFileContent('ws-1', 'file-1', 'workspace/ws-1/123-abc-doc.md', false, options) + return null + } + + function Wrapper({ children }: { children: ReactNode }) { + return {children} + } + + act(() => { + root.render( + + + + ) + }) + return { + unmount: () => { + act(() => root.unmount()) + queryClient.clear() + }, + } +} + +describe('useWorkspaceFileContent refetchInterval passthrough', () => { + it('fetches once and does not poll by default', async () => { + const { unmount } = renderContentHook() + await act(async () => { + await sleep(150) + }) + expect(fetchCount).toBe(1) + unmount() + }) + + it('polls when a numeric refetchInterval is passed', async () => { + const { unmount } = renderContentHook({ refetchInterval: 30 }) + await act(async () => { + await sleep(200) + }) + expect(fetchCount).toBeGreaterThanOrEqual(3) + unmount() + }) + + it('function form is re-evaluated so flipping its condition stops the polling', async () => { + let polling = true + const { unmount } = renderContentHook({ refetchInterval: () => (polling ? 30 : false) }) + await act(async () => { + await sleep(200) + }) + expect(fetchCount).toBeGreaterThanOrEqual(3) + + polling = false + await act(async () => { + await sleep(100) + }) + const settled = fetchCount + await act(async () => { + await sleep(150) + }) + expect(fetchCount).toBe(settled) + unmount() + }) +}) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index edd65bb3984..b58da2b15ab 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -137,12 +137,20 @@ async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Pro * Hook to fetch workspace file content as text. * `key` (the storage object key) is forwarded into the query key factory so that a new * storage key (e.g. after a file is re-uploaded) correctly busts the cache. + * + * `refetchInterval` lets a caller poll while waiting for the server content to advance — the + * editor's post-stream reconcile (see `use-editable-file-content.ts`) exits only when a fetch + * returns content that moved past its baseline, and would otherwise wedge read-only forever if + * its single refetch raced the agent's write. The function form is re-evaluated by react-query + * after every fetch and options pass, so a condition read through a ref stops the polling as soon + * as it flips — no re-render required. */ export function useWorkspaceFileContent( workspaceId: string, fileId: string, key: string, - raw?: boolean + raw?: boolean, + options?: { refetchInterval?: number | false | (() => number | false) } ) { const source = useFileContentSource() return useQuery({ @@ -152,6 +160,7 @@ export function useWorkspaceFileContent( enabled: !!workspaceId && !!fileId && !!key, staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME, refetchOnWindowFocus: 'always', + refetchInterval: options?.refetchInterval ?? false, }) } diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 4a4f26b3271..6e45fe2cad8 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -205,13 +205,14 @@ export async function getApiKeyWithBYOK( const isGeminiModel = provider === 'google' const isMistralModel = provider === 'mistral' const isZaiModel = provider === 'zai' + const isXaiModel = provider === 'xai' const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) if ( isHosted && workspaceId && - (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel) + (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel || isXaiModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index 10a1d589123..0c2be6492bf 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -7,6 +7,7 @@ export const byokProviderIdSchema = z.enum([ 'google', 'mistral', 'zai', + 'xai', 'fireworks', 'together', 'baseten', diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index 01adbbddfc0..e7629109324 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -63,13 +63,27 @@ export const listCustomBlocksQuerySchema = z.object({ workspaceId: workspaceIdSchema, }) +/** + * Icon URLs are rendered as org-wide `` sources, so only https URLs and + * internal file-serve paths (what the icon upload UI stores) are accepted — + * never data:/blob:/other schemes an admin could smuggle into shared metadata. + * Shared with the copilot deploy_custom_block handler's pass-through branch. + */ +export function isAllowedCustomBlockIconUrl(value: string): boolean { + return value.startsWith('https://') || value.startsWith('/api/files/serve/') +} + +const iconUrlSchema = z.string().min(1).max(2048).refine(isAllowedCustomBlockIconUrl, { + message: 'iconUrl must be an https URL or an internal /api/files/serve/ path', +}) + export const publishCustomBlockBodySchema = z.object({ workspaceId: workspaceIdSchema, workflowId: workflowIdSchema, name: z.string().min(1, 'Name is required').max(60, 'Name must be 60 characters or fewer'), description: z.string().max(280, 'Description must be 280 characters or fewer').default(''), - /** Uploaded icon image URL; omit for the default icon. */ - iconUrl: z.string().min(1).max(2048).optional(), + /** Uploaded icon image URL (https or internal serve path); omit for the default icon. */ + iconUrl: iconUrlSchema.optional(), /** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */ inputs: z.array(inputPlaceholderSchema).max(50).optional(), /** Curated outputs; omit/empty to expose the child's whole result. */ @@ -87,8 +101,8 @@ export const updateCustomBlockBodySchema = z name: z.string().min(1).max(60).optional(), description: z.string().max(280).optional(), enabled: z.boolean().optional(), - /** A URL sets/replaces the icon; `null` clears it (default icon). */ - iconUrl: z.string().min(1).max(2048).nullable().optional(), + /** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */ + iconUrl: iconUrlSchema.nullable().optional(), inputs: z.array(inputPlaceholderSchema).max(50).optional(), exposedOutputs: z.array(exposedOutputSchema).max(50).optional(), }) diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts index 1c0159e9ec2..b41cff74d95 100644 --- a/apps/sim/lib/api/contracts/mcp.ts +++ b/apps/sim/lib/api/contracts/mcp.ts @@ -38,9 +38,14 @@ export const mcpTransportSchema = z.enum(['streamable-http']) export const mcpAuthTypeSchema = z.enum(['none', 'headers', 'oauth']) +const consecutiveFailuresSchema = z.preprocess( + (value) => (typeof value === 'number' ? value : undefined), + z.number().default(0) +) + export const mcpServerStatusConfigSchema = z .object({ - consecutiveFailures: z.number().default(0), + consecutiveFailures: consecutiveFailuresSchema, lastSuccessfulDiscovery: z.string().nullable().default(null), }) .passthrough() diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 88d6abbf689..06e3a288e0a 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -237,4 +237,34 @@ describe('buildCopilotRequestPayload', () => { }) ) }) + + it('passes entitlements through and omits the field when empty', async () => { + const withEntitlements = await buildCopilotRequestPayload( + { + message: 'publish as a block', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: 'claude-opus-4-8', + workspaceId: 'ws-1', + entitlements: ['custom-blocks'], + }, + { selectedModel: 'claude-opus-4-8' } + ) + expect(withEntitlements).toEqual(expect.objectContaining({ entitlements: ['custom-blocks'] })) + + const withoutEntitlements = await buildCopilotRequestPayload( + { + message: 'publish as a block', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: 'claude-opus-4-8', + workspaceId: 'ws-1', + entitlements: [], + }, + { selectedModel: 'claude-opus-4-8' } + ) + expect(withoutEntitlements).not.toHaveProperty('entitlements') + }) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 97943990f09..9bea8cddc10 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -41,6 +41,8 @@ interface BuildPayloadParams { workspaceContext?: string vfs?: VfsSnapshotV1 userPermission?: string + /** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */ + entitlements?: string[] userTimezone?: string userMetadata?: { name?: string @@ -387,6 +389,7 @@ export async function buildCopilotRequestPayload( ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), ...(params.vfs ? { vfs: params.vfs } : {}), ...(params.userPermission ? { userPermission: params.userPermission } : {}), + ...(params.entitlements?.length ? { entitlements: params.entitlements } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), ...(params.userMetadata && (params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index ca5943ae3b7..99345d240e6 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -25,6 +25,7 @@ import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, @@ -175,6 +176,7 @@ type UnifiedChatBranch = contexts: Array<{ type: string; content: string; tag?: string; path?: string }> fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string + entitlements?: string[] userTimezone?: string userMetadata?: { name?: string; email?: string; timezone?: string } workflowId: string @@ -212,6 +214,7 @@ type UnifiedChatBranch = contexts: Array<{ type: string; content: string; tag?: string; path?: string }> fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string + entitlements?: string[] userTimezone?: string userMetadata?: { name?: string; email?: string; timezone?: string } workspaceContext?: string @@ -624,6 +627,7 @@ async function resolveBranch(params: { workspaceContext: payloadParams.workspaceContext, vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, + entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, }, @@ -679,6 +683,7 @@ async function resolveBranch(params: { workspaceContext: payloadParams.workspaceContext, vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, + entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, includeMothershipTools: true, @@ -899,6 +904,9 @@ export async function handleUnifiedChatPost(req: NextRequest) { } ) : Promise.resolve(null) + const entitlementsPromise = workspaceId + ? computeWorkspaceEntitlements(workspaceId, authenticatedUserId) + : Promise.resolve([]) // Wrap the pre-LLM prep work in spans so the trace waterfall shows // where time is going between "request received" and "llm.stream // opens". Previously these ran bare under the root and inflated the @@ -953,10 +961,11 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.context ) - const [agentContexts, userPermission, workspaceSnapshot, , executionContext] = + const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] = await Promise.all([ agentContextsPromise, userPermissionPromise, + entitlementsPromise, workspaceContextPromise, persistUserMessagePromise, executionContextPromise, @@ -991,6 +1000,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { contexts: agentContexts, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, + entitlements, userTimezone: body.userTimezone, userMetadata, workflowId: branch.workflowId, @@ -1012,6 +1022,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { contexts: agentContexts, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, + entitlements, userTimezone: body.userTimezone, userMetadata, workspaceContext, diff --git a/apps/sim/lib/copilot/entitlements.ts b/apps/sim/lib/copilot/entitlements.ts new file mode 100644 index 00000000000..8ef560cacea --- /dev/null +++ b/apps/sim/lib/copilot/entitlements.ts @@ -0,0 +1,72 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { LRUCache } from 'lru-cache' +import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations' + +const logger = createLogger('CopilotEntitlements') + +/** + * Cross-repo contract: the mothership (Go) matches these exact strings against + * its `core.Entitlement*` constants to gate agent surfaces. + */ +export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks' + +/** + * Workspace entitlements — plan/flag-gated org capabilities sent to the + * mothership as the chat payload's `entitlements` array. The Go side hides the + * matching tools, skills, and prompt sections when an entitlement is absent, so + * a non-entitled org's agents never hear of the feature. + * + * Adding an entitlement: + * 1. Add the kebab-case name and a fail-closed evaluator here. Every payload + * site (interactive chat, headless execute, inbox) picks it up automatically. + * 2. Go repo: add the matching `Entitlement*` constant in `internal/core` and + * gate surfaces declaratively — `RequiredEntitlement` on tool definitions, + * `entitlement:` frontmatter on skills, a conditional section in + * `BuildAgentEnvelope`, or a variant swap in `Capabilities`. + * 3. Keep enforcement in sim: the Go gating is advertisement-only (the payload + * is forgeable), so the sim-side tool handler must re-check the same + * predicate at execution time. + */ +const ENTITLEMENT_EVALUATORS: Record< + string, + (workspaceId: string, userId?: string) => Promise +> = { + [CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible, +} + +const entitlementsCache = new LRUCache>({ + max: 500, + ttl: 5_000, +}) + +/** + * The entitlements to send to the mothership for a request in this workspace. + * Each evaluator fails closed (an error means the entitlement is absent). + * Cached briefly so the several per-message callers collapse to one evaluation. + */ +export function computeWorkspaceEntitlements( + workspaceId: string, + userId?: string +): Promise { + const cacheKey = `${workspaceId}:${userId ?? ''}` + const cached = entitlementsCache.get(cacheKey) + if (cached) return cached + + const promise = Promise.all( + Object.entries(ENTITLEMENT_EVALUATORS).map(async ([name, evaluate]) => { + try { + return (await evaluate(workspaceId, userId)) ? name : null + } catch (error) { + logger.warn('Entitlement evaluation failed; treating as absent', { + entitlement: name, + workspaceId, + error: getErrorMessage(error), + }) + return null + } + }) + ).then((names) => names.filter((name): name is string => name !== null)) + entitlementsCache.set(cacheKey, promise) + return promise +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4a4c24a1594..cce8011cd3e 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -23,6 +23,7 @@ export interface ToolCatalogEntry { | 'deploy' | 'deploy_api' | 'deploy_chat' + | 'deploy_custom_block' | 'deploy_mcp' | 'diff_workflows' | 'download_to_workspace_file' @@ -121,6 +122,7 @@ export interface ToolCatalogEntry { | 'deploy' | 'deploy_api' | 'deploy_chat' + | 'deploy_custom_block' | 'deploy_mcp' | 'diff_workflows' | 'download_to_workspace_file' @@ -536,7 +538,7 @@ export const Deploy: ToolCatalogEntry = { properties: { request: { description: - 'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', + 'Detailed deployment instructions. Include the deployment type and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', type: 'string', }, }, @@ -772,6 +774,119 @@ export const DeployChat: ToolCatalogEntry = { requiredPermission: 'admin', } +export const DeployCustomBlock: ToolCatalogEntry = { + id: 'deploy_custom_block', + name: 'deploy_custom_block', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Block UUID inside the workflow' }, + name: { type: 'string', description: 'Friendly output name shown on the block' }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Stable id of the input trigger field' }, + placeholder: { + type: 'string', + description: "Placeholder text shown in the block's input field", + }, + }, + required: ['id'], + }, + }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + }, + workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, + }, + required: ['name'], + }, + resultSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { type: 'string', description: 'Custom block record ID.' }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', + description: + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", + }, + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { + type: 'string', + description: + 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { type: 'string', description: 'Display name of the custom block.' }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', + }, + workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' }, + }, + required: ['deploymentType', 'deploymentStatus'], + }, + requiredPermission: 'admin', +} + export const DeployMcp: ToolCatalogEntry = { id: 'deploy_mcp', name: 'deploy_mcp', @@ -4581,6 +4696,7 @@ export const TOOL_CATALOG: Record = { [Deploy.id]: Deploy, [DeployApi.id]: DeployApi, [DeployChat.id]: DeployChat, + [DeployCustomBlock.id]: DeployCustomBlock, [DeployMcp.id]: DeployMcp, [DiffWorkflows.id]: DiffWorkflows, [DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index dcaea0db6ea..bdb97241d30 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { + ['complete_scheduled_task']: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { + ['create_file_folder']: { parameters: { type: 'object', properties: { @@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workflow: { + ['create_workflow']: { parameters: { type: 'object', properties: { @@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_file_folder: { + ['delete_file_folder']: { parameters: { type: 'object', properties: { @@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -313,12 +313,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { description: - 'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', + 'Detailed deployment instructions. Include the deployment type and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', type: 'string', }, }, @@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -570,7 +570,135 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_mcp: { + ['deploy_custom_block']: { + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Block UUID inside the workflow', + }, + name: { + type: 'string', + description: 'Friendly output name shown on the block', + }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Stable id of the input trigger field', + }, + placeholder: { + type: 'string', + description: "Placeholder text shown in the block's input field", + }, + }, + required: ['id'], + }, + }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (defaults to active workflow)', + }, + }, + required: ['name'], + }, + resultSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { + type: 'string', + description: 'Custom block record ID.', + }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', + description: + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", + }, + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { + type: 'string', + description: + 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { + type: 'string', + description: 'Display name of the custom block.', + }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', + }, + workflowId: { + type: 'string', + description: 'Workflow ID the custom block is bound to.', + }, + }, + required: ['deploymentType', 'deploymentStatus'], + }, + }, + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -686,7 +814,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - diff_workflows: { + ['diff_workflows']: { parameters: { type: 'object', properties: { @@ -710,7 +838,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -759,7 +887,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -791,7 +919,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -830,7 +958,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + ['enrichment_run']: { parameters: { type: 'object', properties: { @@ -874,7 +1002,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ffmpeg: { + ['ffmpeg']: { parameters: { type: 'object', properties: { @@ -1055,7 +1183,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { properties: { prompt: { @@ -1068,7 +1196,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -1206,7 +1334,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -1224,7 +1352,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_audio: { + ['generate_audio']: { parameters: { type: 'object', properties: { @@ -1376,7 +1504,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -1504,7 +1632,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_video: { + ['generate_video']: { parameters: { type: 'object', properties: { @@ -1671,7 +1799,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -1692,7 +1820,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1714,7 +1842,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1727,7 +1855,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + ['get_deployment_log']: { parameters: { type: 'object', properties: { @@ -1740,7 +1868,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1768,14 +1896,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_scheduled_task_logs: { + ['get_scheduled_task_logs']: { parameters: { type: 'object', properties: { @@ -1800,7 +1928,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1819,7 +1947,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_run_options: { + ['get_workflow_run_options']: { parameters: { type: 'object', properties: { @@ -1832,7 +1960,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1851,7 +1979,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -1899,7 +2027,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -1912,7 +2040,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -2105,7 +2233,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { + ['list_file_folders']: { parameters: { type: 'object', properties: { @@ -2117,7 +2245,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_integration_tools: { + ['list_integration_tools']: { parameters: { properties: { integration: { @@ -2131,14 +2259,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -2151,7 +2279,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_deployment: { + ['load_deployment']: { parameters: { type: 'object', properties: { @@ -2170,7 +2298,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_integration_tool: { + ['load_integration_tool']: { parameters: { properties: { tool_ids: { @@ -2187,7 +2315,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -2216,7 +2344,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -2296,7 +2424,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { + ['manage_folder']: { parameters: { type: 'object', properties: { @@ -2335,7 +2463,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_mcp_tool: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -2387,7 +2515,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { + ['manage_scheduled_task']: { parameters: { type: 'object', properties: { @@ -2462,7 +2590,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -2495,7 +2623,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -2519,7 +2647,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - media: { + ['media']: { parameters: { properties: { prompt: { @@ -2532,7 +2660,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + ['move_file']: { parameters: { type: 'object', properties: { @@ -2553,7 +2681,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file_folder: { + ['move_file_folder']: { parameters: { type: 'object', properties: { @@ -2571,7 +2699,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_workflow: { + ['move_workflow']: { parameters: { type: 'object', properties: { @@ -2591,7 +2719,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { @@ -2605,7 +2733,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -2619,7 +2747,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -2653,7 +2781,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - promote_to_live: { + ['promote_to_live']: { parameters: { type: 'object', properties: { @@ -2672,7 +2800,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_logs: { + ['query_logs']: { parameters: { type: 'object', properties: { @@ -2783,7 +2911,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - read: { + ['read']: { parameters: { type: 'object', properties: { @@ -2810,7 +2938,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -2889,7 +3017,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { + ['rename_file']: { parameters: { type: 'object', properties: { @@ -2925,7 +3053,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - rename_file_folder: { + ['rename_file_folder']: { parameters: { type: 'object', properties: { @@ -2942,7 +3070,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - rename_workflow: { + ['rename_workflow']: { parameters: { type: 'object', properties: { @@ -2959,7 +3087,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - research: { + ['research']: { parameters: { properties: { topic: { @@ -2972,7 +3100,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -2995,7 +3123,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -3013,7 +3141,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -3030,7 +3158,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -3062,7 +3190,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -3094,7 +3222,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -3132,7 +3260,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -3175,7 +3303,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { + ['scheduled_task']: { parameters: { properties: { request: { @@ -3188,7 +3316,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -3209,7 +3337,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -3226,7 +3354,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_library_docs: { + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -3247,7 +3375,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -3287,7 +3415,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -3309,7 +3437,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -3331,7 +3459,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -3365,7 +3493,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -3406,7 +3534,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { + ['superagent']: { parameters: { properties: { task: { @@ -3420,7 +3548,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - table: { + ['table']: { parameters: { properties: { request: { @@ -3433,7 +3561,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_deployment_version: { + ['update_deployment_version']: { parameters: { type: 'object', properties: { @@ -3462,7 +3590,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { + ['update_scheduled_task_history']: { parameters: { type: 'object', properties: { @@ -3480,7 +3608,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -3505,7 +3633,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { + ['user_memory']: { parameters: { type: 'object', properties: { @@ -3554,7 +3682,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -3917,7 +4045,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { properties: { prompt: { @@ -3930,7 +4058,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b39027e3526..999dea91b28 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -8,6 +8,7 @@ import { DeleteWorkspaceMcpServer, DeployApi, DeployChat, + DeployCustomBlock, DeployMcp, DiffWorkflows, FunctionExecute, @@ -53,6 +54,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' +import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' import { executeDeployApi, executeDeployChat, @@ -156,6 +158,7 @@ function buildHandlerMap(): Record { [DeployApi.id]: h(executeDeployApi), [DeployChat.id]: h(executeDeployChat), [DeployMcp.id]: h(executeDeployMcp), + [DeployCustomBlock.id]: h(executeDeployCustomBlock), [Redeploy.id]: h(executeRedeploy), [CheckDeploymentStatus.id]: h(executeCheckDeploymentStatus), [ListWorkspaceMcpServers.id]: h(executeListWorkspaceMcpServers), diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts new file mode 100644 index 00000000000..480d33a801e --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -0,0 +1,437 @@ +/** + * @vitest-environment node + */ + +import { auditMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/lib/copilot/request/types' + +const { + ensureWorkflowAccessMock, + getWorkspaceWithOwnerMock, + isFeatureEnabledMock, + isOrganizationOnEnterprisePlanMock, + publishCustomBlockMock, + updateCustomBlockMock, + deleteCustomBlockMock, + getCustomBlockWithInputsByWorkflowIdMock, + listWorkspaceFilesMock, + fetchWorkspaceFileBufferMock, + uploadFileMock, +} = vi.hoisted(() => ({ + ensureWorkflowAccessMock: vi.fn(), + getWorkspaceWithOwnerMock: vi.fn(), + isFeatureEnabledMock: vi.fn(), + isOrganizationOnEnterprisePlanMock: vi.fn(), + publishCustomBlockMock: vi.fn(), + updateCustomBlockMock: vi.fn(), + deleteCustomBlockMock: vi.fn(), + getCustomBlockWithInputsByWorkflowIdMock: vi.fn(), + listWorkspaceFilesMock: vi.fn(), + fetchWorkspaceFileBufferMock: vi.fn(), + uploadFileMock: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('../access', () => ({ + ensureWorkflowAccess: ensureWorkflowAccessMock, + ensureWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: getWorkspaceWithOwnerMock, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: isFeatureEnabledMock, +})) + +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + listWorkspaceFiles: listWorkspaceFilesMock, + fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: uploadFileMock, +})) + +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + isImageFileType: (type: string) => type.startsWith('image/'), +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => { + class CustomBlockValidationError extends Error {} + return { + CustomBlockValidationError, + publishCustomBlock: publishCustomBlockMock, + updateCustomBlock: updateCustomBlockMock, + deleteCustomBlock: deleteCustomBlockMock, + getCustomBlockWithInputsByWorkflowId: getCustomBlockWithInputsByWorkflowIdMock, + } +}) + +import { executeDeployCustomBlock } from './custom-block' + +const context = { userId: 'user-1', workflowId: 'wf-1' } as ExecutionContext + +const publishedBlock = { + id: 'cb-1', + organizationId: 'org-1', + workflowId: 'wf-1', + workflowName: 'Test Workflow', + workspaceId: 'ws-1', + workspaceName: 'Workspace', + type: 'custom_block_abc123', + name: 'Enrich Lead', + description: 'Enrich a lead by email', + iconUrl: null, + enabled: true, + inputFields: [{ id: 'f1', name: 'email', type: 'string' }], + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], +} + +describe('executeDeployCustomBlock', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: true }, + }) + getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: 'org-1' }) + isFeatureEnabledMock.mockResolvedValue(true) + isOrganizationOnEnterprisePlanMock.mockResolvedValue(true) + getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(null) + }) + + it('publishes a new custom block', async () => { + publishCustomBlockMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock( + { + name: 'Enrich Lead', + description: 'Enrich a lead by email', + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], + }, + context + ) + + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(publishCustomBlockMock).toHaveBeenCalledWith({ + organizationId: 'org-1', + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-1', + name: 'Enrich Lead', + description: 'Enrich a lead by email', + iconUrl: undefined, + inputs: undefined, + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + workflowId: 'wf-1', + blockType: 'custom_block_abc123', + isDeployed: true, + updated: false, + deploymentType: 'custom_block', + deploymentStatus: { customBlock: { isDeployed: true, name: 'Enrich Lead' } }, + }) + }) + + it('returns a clean admin-permission error when workflow access is denied', async () => { + ensureWorkflowAccessMock.mockRejectedValue(new Error('Unauthorized workflow access')) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('admin permission') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('surfaces workflow-not-found from access resolution', async () => { + ensureWorkflowAccessMock.mockRejectedValue(new Error('Workflow wf-1 not found')) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found') + }) + + it('requires a name on first publish', async () => { + const result = await executeDeployCustomBlock({}, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('name is required') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('requires the workflow to be deployed on first publish', async () => { + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: false }, + }) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('deploy_api') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('updates an existing block in place', async () => { + getCustomBlockWithInputsByWorkflowIdMock + .mockResolvedValueOnce(publishedBlock) + .mockResolvedValueOnce({ ...publishedBlock, name: 'Enrich Lead v2' }) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead v2' }, context) + + expect(updateCustomBlockMock).toHaveBeenCalledWith('cb-1', { + name: 'Enrich Lead v2', + description: undefined, + iconUrl: undefined, + inputs: undefined, + exposedOutputs: undefined, + }) + expect(publishCustomBlockMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ updated: true, name: 'Enrich Lead v2' }) + }) + + it('unpublishes the block on undeploy', async () => { + getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) + + expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1') + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + isDeployed: false, + removed: true, + action: 'undeploy', + blockType: 'custom_block_abc123', + }) + }) + + it('updates an existing block without requiring the enterprise plan', async () => { + isOrganizationOnEnterprisePlanMock.mockResolvedValue(false) + getCustomBlockWithInputsByWorkflowIdMock + .mockResolvedValueOnce(publishedBlock) + .mockResolvedValueOnce(publishedBlock) + + const result = await executeDeployCustomBlock({ description: 'refreshed copy' }, context) + + expect(result.success).toBe(true) + expect(updateCustomBlockMock).toHaveBeenCalled() + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('undeploys without requiring the enterprise plan', async () => { + isOrganizationOnEnterprisePlanMock.mockResolvedValue(false) + getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) + + expect(result.success).toBe(true) + expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1') + }) + + it('does not clear the stored name when a republish sends whitespace', async () => { + getCustomBlockWithInputsByWorkflowIdMock + .mockResolvedValueOnce(publishedBlock) + .mockResolvedValueOnce(publishedBlock) + + const result = await executeDeployCustomBlock({ name: ' ' }, context) + + expect(updateCustomBlockMock).toHaveBeenCalledWith( + 'cb-1', + expect.objectContaining({ name: undefined }) + ) + expect(result.success).toBe(true) + }) + + it('rejects oversized exposedOutputs and inputs arrays', async () => { + const outputs = Array.from({ length: 51 }, (_, i) => ({ + blockId: `b${i}`, + path: 'content', + name: `out${i}`, + })) + const tooManyOutputs = await executeDeployCustomBlock( + { name: 'Enrich Lead', exposedOutputs: outputs }, + context + ) + expect(tooManyOutputs.success).toBe(false) + expect(tooManyOutputs.error).toContain('50') + + const inputs = Array.from({ length: 51 }, (_, i) => ({ id: `f${i}` })) + const tooManyInputs = await executeDeployCustomBlock({ name: 'Enrich Lead', inputs }, context) + expect(tooManyInputs.success).toBe(false) + expect(tooManyInputs.error).toContain('50') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('rejects oversized per-item fields', async () => { + const longPlaceholder = await executeDeployCustomBlock( + { name: 'Enrich Lead', inputs: [{ id: 'f1', placeholder: 'x'.repeat(201) }] }, + context + ) + expect(longPlaceholder.success).toBe(false) + expect(longPlaceholder.error).toContain('200') + + const longOutputName = await executeDeployCustomBlock( + { + name: 'Enrich Lead', + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'x'.repeat(61) }], + }, + context + ) + expect(longOutputName.success).toBe(false) + expect(longOutputName.error).toContain('60') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('rejects exposedOutputs entries missing required fields', async () => { + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', exposedOutputs: [{ blockId: 'b1', path: '', name: 'out' }] }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('blockId, path, and name') + }) + + it('fails undeploy when the workflow is not published as a block', async () => { + const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) + + expect(result.success).toBe(false) + expect(deleteCustomBlockMock).not.toHaveBeenCalled() + }) + + it('fails when the feature flag is off', async () => { + isFeatureEnabledMock.mockResolvedValue(false) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('not enabled') + }) + + it('fails when the org is not on the enterprise plan', async () => { + isOrganizationOnEnterprisePlanMock.mockResolvedValue(false) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('enterprise') + }) + + it('ingests a workspace-file icon into public icon storage', async () => { + listWorkspaceFilesMock.mockResolvedValue([ + { + name: 'icon.png', + folderPath: null, + type: 'image/png', + size: 1024, + key: 'workspace/ws-1/123-abc-icon.png', + }, + ]) + fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes')) + uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) + publishCustomBlockMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'files/icon.png' }, + context + ) + + expect(uploadFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: 'workspace-logos', + contentType: 'image/png', + customKey: expect.stringMatching(/^workspace-logos\/\d+-[A-Za-z0-9_-]+-icon\.png$/), + preserveKey: true, + metadata: { workspaceId: 'ws-1', userId: 'user-1', originalName: 'icon.png' }, + }) + ) + expect(publishCustomBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ iconUrl: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) + ) + expect(result.success).toBe(true) + }) + + it('passes an external icon URL through without ingestion', async () => { + publishCustomBlockMock.mockResolvedValue(publishedBlock) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'https://example.com/icon.png' }, + context + ) + + expect(uploadFileMock).not.toHaveBeenCalled() + expect(publishCustomBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ iconUrl: 'https://example.com/icon.png' }) + ) + expect(result.success).toBe(true) + }) + + it('fails when the icon workspace file does not exist', async () => { + listWorkspaceFilesMock.mockResolvedValue([]) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'files/missing.png' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found') + expect(publishCustomBlockMock).not.toHaveBeenCalled() + }) + + it('rejects non-https icon URL schemes on pass-through', async () => { + const dataUri = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+' }, + context + ) + expect(dataUri.success).toBe(false) + expect(dataUri.error).toContain('https') + + const plainHttp = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'http://example.com/icon.png' }, + context + ) + expect(plainHttp.success).toBe(false) + expect(publishCustomBlockMock).not.toHaveBeenCalled() + + publishCustomBlockMock.mockResolvedValue(publishedBlock) + const servePath = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: '/api/files/serve/workspace-logos%2Ficon.png' }, + context + ) + expect(servePath.success).toBe(true) + }) + + it('fails when the icon workspace file is not an image', async () => { + listWorkspaceFilesMock.mockResolvedValue([ + { name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' }, + ]) + + const result = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'files/notes.pdf' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('image') + expect(uploadFileMock).not.toHaveBeenCalled() + }) + + it('fails when the workspace has no organization', async () => { + getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: null }) + + const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('organization') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts new file mode 100644 index 00000000000..36268f6216f --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -0,0 +1,289 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { + fetchWorkspaceFileBuffer, + listWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { uploadFile } from '@/lib/uploads/core/storage-service' +import { isImageFileType } from '@/lib/uploads/utils/file-utils' +import { + CustomBlockValidationError, + type CustomBlockWithInputs, + deleteCustomBlock, + getCustomBlockWithInputsByWorkflowId, + publishCustomBlock, + updateCustomBlock, +} from '@/lib/workflows/custom-blocks/operations' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { ensureWorkflowAccess } from '../access' +import type { DeployCustomBlockParams } from '../param-types' + +const MAX_ICON_BYTES = 5 * 1024 * 1024 +const MAX_INPUT_ENTRIES = 50 +const MAX_OUTPUT_ENTRIES = 50 + +/** + * Resolve the agent-supplied icon reference to a publicly servable URL. A VFS + * workspace-file path (`files/...`) is ingested: the file is copied into the + * world-readable `workspace-logos` storage context (the same context the icon + * upload UI writes to), because a raw workspace-file URL is membership-gated + * and the block's icon must render for org members in other workspaces. Any + * other non-empty value (an external or already-public URL) passes through. + */ +async function resolveIconUrl( + raw: string | undefined, + userId: string, + workspaceId: string +): Promise { + const value = raw?.trim() + if (!value) return undefined + if (!value.startsWith('files/')) { + if (!isAllowedCustomBlockIconUrl(value)) { + throw new CustomBlockValidationError( + 'iconUrl must be an https URL, an internal /api/files/serve/ path, or a workspace file path (files/...)' + ) + } + return value + } + + const canonical = canonicalizeVfsPath(value) + const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true }) + const record = files.find( + (f) => canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === canonical + ) + if (!record) { + throw new CustomBlockValidationError(`Icon file not found in this workspace: ${value}`) + } + if (!isImageFileType(record.type)) { + throw new CustomBlockValidationError( + 'Icon file must be an image (PNG, JPEG, GIF, WebP, or SVG)' + ) + } + if (record.size > MAX_ICON_BYTES) { + throw new CustomBlockValidationError('Icon file must be 5MB or smaller') + } + + const buffer = await fetchWorkspaceFileBuffer(record) + const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') + const uploaded = await uploadFile({ + file: buffer, + fileName: record.name, + contentType: record.type, + context: 'workspace-logos', + customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`, + preserveKey: true, + metadata: { workspaceId, userId, originalName: record.name }, + }) + return uploaded.path +} + +function customBlockOutput(block: CustomBlockWithInputs, action: 'deploy' | 'undeploy') { + const isDeployed = action === 'deploy' + return { + workflowId: block.workflowId, + blockId: block.id, + blockType: block.type, + name: block.name, + action, + isDeployed, + removed: !isDeployed, + deploymentType: 'custom_block', + deploymentStatus: { + customBlock: { + isDeployed, + blockType: block.type, + name: block.name, + enabled: block.enabled, + }, + }, + deploymentConfig: { + customBlock: { + blockType: block.type, + name: block.name, + description: block.description, + iconUrl: block.iconUrl, + inputFields: block.inputFields, + exposedOutputs: block.exposedOutputs, + organizationId: block.organizationId, + }, + }, + } +} + +export async function executeDeployCustomBlock( + params: DeployCustomBlockParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId || context.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' + + let workflowRecord: Awaited>['workflow'] + try { + workflowRecord = (await ensureWorkflowAccess(workflowId, context.userId, 'admin')).workflow + } catch (error) { + const message = toError(error).message + if (message.includes('not found')) { + return { success: false, error: message } + } + return { + success: false, + error: "Managing a custom block requires admin permission on the workflow's workspace", + } + } + const workspaceId = workflowRecord.workspaceId + if (!workspaceId) { + return { success: false, error: 'Workflow must belong to a workspace' } + } + + const ws = await getWorkspaceWithOwner(workspaceId) + const organizationId = ws?.organizationId + if (!organizationId) { + return { + success: false, + error: 'Publishing a block requires the workspace to belong to an organization', + } + } + if ( + !(await isFeatureEnabled('deploy-as-block', { + userId: context.userId, + orgId: organizationId, + })) + ) { + return { success: false, error: 'Custom blocks are not enabled for this organization' } + } + + const existing = await getCustomBlockWithInputsByWorkflowId(workflowId) + + if (action === 'undeploy') { + if (!existing) { + return { success: false, error: 'This workflow is not published as a custom block' } + } + await deleteCustomBlock(existing.id) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_BLOCK_DELETED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: existing.id, + resourceName: existing.name, + description: `Unpublished custom block "${existing.name}"`, + metadata: { organizationId, type: existing.type, workflowId, source: 'copilot' }, + }) + return { success: true, output: customBlockOutput(existing, 'undeploy') } + } + + const name = params.name?.trim() + const description = params.description?.trim() + if (name && name.length > 60) { + return { success: false, error: 'name must be 60 characters or fewer' } + } + if (description && description.length > 280) { + return { success: false, error: 'description must be 280 characters or fewer' } + } + if (params.inputs && params.inputs.length > MAX_INPUT_ENTRIES) { + return { success: false, error: `inputs must be ${MAX_INPUT_ENTRIES} entries or fewer` } + } + if (params.inputs?.some((entry) => !entry?.id?.trim())) { + return { success: false, error: 'each inputs entry requires the trigger field id' } + } + if (params.inputs?.some((entry) => (entry.placeholder?.length ?? 0) > 200)) { + return { success: false, error: 'input placeholders must be 200 characters or fewer' } + } + if (params.exposedOutputs && params.exposedOutputs.length > MAX_OUTPUT_ENTRIES) { + return { + success: false, + error: `exposedOutputs must be ${MAX_OUTPUT_ENTRIES} entries or fewer`, + } + } + if ( + params.exposedOutputs?.some( + (entry) => !entry?.blockId?.trim() || !entry?.path?.trim() || !entry?.name?.trim() + ) + ) { + return { + success: false, + error: 'each exposedOutputs entry requires blockId, path, and name', + } + } + if (params.exposedOutputs?.some((entry) => entry.name.length > 60)) { + return { success: false, error: 'exposed output names must be 60 characters or fewer' } + } + const iconUrl = await resolveIconUrl(params.iconUrl, context.userId, workspaceId) + + if (existing) { + await updateCustomBlock(existing.id, { + name: name || undefined, + description, + iconUrl, + inputs: params.inputs, + exposedOutputs: params.exposedOutputs, + }) + const updated = await getCustomBlockWithInputsByWorkflowId(workflowId) + if (!updated) { + return { success: false, error: 'Custom block not found after update' } + } + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_BLOCK_UPDATED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: updated.id, + resourceName: updated.name, + description: `Updated custom block "${updated.name}"`, + metadata: { organizationId, type: updated.type, workflowId, source: 'copilot' }, + }) + return { success: true, output: { ...customBlockOutput(updated, 'deploy'), updated: true } } + } + + if (!(await isOrganizationOnEnterprisePlan(organizationId))) { + return { success: false, error: 'Custom blocks require an enterprise plan' } + } + if (!name) { + return { success: false, error: 'name is required when publishing a new custom block' } + } + if (!workflowRecord.isDeployed) { + return { + success: false, + error: + 'Workflow must be deployed before publishing as a custom block. Use deploy_api first.', + } + } + const block = await publishCustomBlock({ + organizationId, + workspaceId, + workflowId, + userId: context.userId, + name, + description: description ?? '', + iconUrl, + inputs: params.inputs, + exposedOutputs: params.exposedOutputs, + }) + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.CUSTOM_BLOCK_PUBLISHED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: block.id, + resourceName: block.name, + description: `Published custom block "${block.name}"`, + metadata: { organizationId, type: block.type, workflowId, source: 'copilot' }, + }) + return { success: true, output: { ...customBlockOutput(block, 'deploy'), updated: false } } + } catch (error) { + if (error instanceof CustomBlockValidationError) { + return { success: false, error: error.message } + } + return { success: false, error: toError(error).message } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index da437b4993a..19eae663ba8 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -173,6 +173,24 @@ export interface DeployMcpParams { parameterDescriptions?: Array<{ name: string; description: string }> } +export interface DeployCustomBlockParams { + workflowId?: string + action?: 'deploy' | 'undeploy' + /** Block display name (max 60 chars). Required on first publish. */ + name?: string + /** Block-picker description (max 280 chars). */ + description?: string + /** Icon image URL; omit for the organization's default icon. */ + iconUrl?: string + /** + * Per-input placeholder overrides keyed by the input trigger field's stable id. + * The field set itself is always derived from the workflow's deployment. + */ + inputs?: Array<{ id: string; placeholder?: string }> + /** Curated outputs; omit to expose the terminal block's whole result. */ + exposedOutputs?: Array<{ blockId: string; path: string; name: string }> +} + export interface CheckDeploymentStatusParams { workflowId?: string } diff --git a/apps/sim/lib/core/config/api-keys.ts b/apps/sim/lib/core/config/api-keys.ts index 1a1f04218cd..b435da46f22 100644 --- a/apps/sim/lib/core/config/api-keys.ts +++ b/apps/sim/lib/core/config/api-keys.ts @@ -12,7 +12,8 @@ export function getRotatingApiKey(provider: string): string { provider !== 'anthropic' && provider !== 'gemini' && provider !== 'cohere' && - provider !== 'zai' + provider !== 'zai' && + provider !== 'xai' ) { throw new Error(`No rotation implemented for provider: ${provider}`) } @@ -39,6 +40,10 @@ export function getRotatingApiKey(provider: string): string { if (env.ZAI_API_KEY_1) keys.push(env.ZAI_API_KEY_1) if (env.ZAI_API_KEY_2) keys.push(env.ZAI_API_KEY_2) if (env.ZAI_API_KEY_3) keys.push(env.ZAI_API_KEY_3) + } else if (provider === 'xai') { + if (env.XAI_API_KEY_1) keys.push(env.XAI_API_KEY_1) + if (env.XAI_API_KEY_2) keys.push(env.XAI_API_KEY_2) + if (env.XAI_API_KEY_3) keys.push(env.XAI_API_KEY_3) } if (keys.length === 0) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 717a6f5eb2b..a7e56751caf 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -147,6 +147,9 @@ export const env = createEnv({ ZAI_API_KEY_1: z.string().min(1).optional(), // Primary Z.ai API key for load balancing ZAI_API_KEY_2: z.string().min(1).optional(), // Additional Z.ai API key for load balancing ZAI_API_KEY_3: z.string().min(1).optional(), // Additional Z.ai API key for load balancing + XAI_API_KEY_1: z.string().min(1).optional(), // Primary xAI API key for load balancing + XAI_API_KEY_2: z.string().min(1).optional(), // Additional xAI API key for load balancing + XAI_API_KEY_3: z.string().min(1).optional(), // Additional xAI API key for load balancing OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL VLLM_BASE_URL: z.string().url().optional(), // vLLM self-hosted base URL (OpenAI-compatible) VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM diff --git a/apps/sim/lib/core/security/url-safety.test.ts b/apps/sim/lib/core/security/url-safety.test.ts new file mode 100644 index 00000000000..122dc22b8ec --- /dev/null +++ b/apps/sim/lib/core/security/url-safety.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { isAllowedExternalUrl, sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety' + +describe('isAllowedExternalUrl', () => { + it('allows http, https, and mailto URLs', () => { + expect(isAllowedExternalUrl('https://example.com/deck')).toBe(true) + expect(isAllowedExternalUrl('http://example.com/deck')).toBe(true) + expect(isAllowedExternalUrl('mailto:support@example.com')).toBe(true) + }) + + it('rejects scriptable, data, and relative URLs', () => { + expect(isAllowedExternalUrl('javascript:alert(1)')).toBe(false) + expect(isAllowedExternalUrl('data:text/html,')).toBe(false) + expect(isAllowedExternalUrl('/workspace/files')).toBe(false) + }) +}) + +describe('sanitizeRenderedHyperlinks', () => { + function containerWithAnchor(href: string): HTMLDivElement { + const container = document.createElement('div') + const anchor = document.createElement('a') + anchor.setAttribute('href', href) + anchor.textContent = 'link' + container.appendChild(anchor) + return container + } + + it('strips javascript: hrefs from a docx-preview hyperlink rendering', () => { + const container = containerWithAnchor( + "javascript:document.body.setAttribute('data-xss-fired','1')" + ) + sanitizeRenderedHyperlinks(container) + const anchor = container.querySelector('a') + expect(anchor?.hasAttribute('href')).toBe(false) + }) + + it('strips data: and vbscript: hrefs', () => { + for (const href of ['data:text/html,', 'vbscript:msgbox(1)']) { + const container = containerWithAnchor(href) + sanitizeRenderedHyperlinks(container) + expect(container.querySelector('a')?.hasAttribute('href')).toBe(false) + } + }) + + it('preserves same-document bookmark anchors', () => { + const container = containerWithAnchor('#section-2') + sanitizeRenderedHyperlinks(container) + expect(container.querySelector('a')?.getAttribute('href')).toBe('#section-2') + }) + + it('keeps allowed external links and adds rel=noopener noreferrer', () => { + const container = containerWithAnchor('https://example.com/report') + sanitizeRenderedHyperlinks(container) + const anchor = container.querySelector('a') + expect(anchor?.getAttribute('href')).toBe('https://example.com/report') + expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer') + }) +}) diff --git a/apps/sim/lib/core/security/url-safety.ts b/apps/sim/lib/core/security/url-safety.ts new file mode 100644 index 00000000000..694ad516310 --- /dev/null +++ b/apps/sim/lib/core/security/url-safety.ts @@ -0,0 +1,37 @@ +/** + * URL safety utilities for external hyperlinks/media in untrusted document content + * (PPTX, DOCX, and other previews rendered into the app origin). + */ + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']) + +/** + * Returns true only for absolute URLs with an allowed protocol. + */ +export function isAllowedExternalUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase()) + } catch { + return false + } +} + +/** + * Neutralizes anchors rendered from untrusted document content (e.g. docx-preview, + * which copies an external-relationship `Target` straight into `href` with no scheme + * check). Same-document fragment links (`#bookmark`) are left intact; anything else + * that isn't http/https/mailto has its `href` stripped so the anchor can't navigate. + * Surviving external links get `rel="noopener noreferrer"` to block tabnabbing. + */ +export function sanitizeRenderedHyperlinks(root: ParentNode): void { + for (const anchor of root.querySelectorAll('a[href]')) { + const href = anchor.getAttribute('href') ?? '' + if (href.startsWith('#')) continue + if (isAllowedExternalUrl(href)) { + anchor.setAttribute('rel', 'noopener noreferrer') + continue + } + anchor.removeAttribute('href') + } +} diff --git a/apps/sim/lib/core/utils.test.ts b/apps/sim/lib/core/utils.test.ts index a9e2b1662f9..7ccb5dc75cf 100644 --- a/apps/sim/lib/core/utils.test.ts +++ b/apps/sim/lib/core/utils.test.ts @@ -43,6 +43,9 @@ vi.mock('@/lib/core/config/env', () => GEMINI_API_KEY_1: 'test-gemini-key-1', GEMINI_API_KEY_2: 'test-gemini-key-2', GEMINI_API_KEY_3: 'test-gemini-key-3', + XAI_API_KEY_1: 'test-xai-key-1', + XAI_API_KEY_2: 'test-xai-key-2', + XAI_API_KEY_3: 'test-xai-key-3', }) ) @@ -327,6 +330,11 @@ describe('getRotatingApiKey', () => { expect(result).toMatch(/^test-gemini-key-[1-3]$/) }) + it.concurrent('should return xAI API key based on current minute', () => { + const result = getRotatingApiKey('xai') + expect(result).toMatch(/^test-xai-key-[1-3]$/) + }) + it.concurrent('should throw error for unsupported provider', () => { expect(() => getRotatingApiKey('unsupported')).toThrow('No rotation implemented for provider') }) diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 12c30e7c8c3..7e97d3fc2de 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -328,11 +328,14 @@ class McpService { ) .limit(1) - const currentConfig: McpServerStatusConfig = - (currentServer?.statusConfig as McpServerStatusConfig | null) ?? { - consecutiveFailures: 0, - lastSuccessfulDiscovery: null, - } + const storedConfig = currentServer?.statusConfig as Partial | null + const currentConfig: McpServerStatusConfig = { + consecutiveFailures: + typeof storedConfig?.consecutiveFailures === 'number' + ? storedConfig.consecutiveFailures + : 0, + lastSuccessfulDiscovery: storedConfig?.lastSuccessfulDiscovery ?? null, + } const now = new Date() diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 86ac2312b2d..903018024d6 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -13,6 +13,7 @@ import { } from '@/lib/copilot/chat/persisted-message' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' @@ -208,14 +209,21 @@ export async function executeInboxTask(taskId: string): Promise { return { attachments, ...downloaded } } - const [attachmentResult, workspaceContext, integrationTools, userSkillTool, userPermission] = - await Promise.all([ - fetchAttachments(), - generateWorkspaceContext(ws.id, userId), - buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - buildUserSkillTool(ws.id), - getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), - ]) + const [ + attachmentResult, + workspaceContext, + integrationTools, + userSkillTool, + userPermission, + entitlements, + ] = await Promise.all([ + fetchAttachments(), + generateWorkspaceContext(ws.id, userId), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + buildUserSkillTool(ws.id), + getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), + computeWorkspaceEntitlements(ws.id, userId), + ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult const truncatedTask = { @@ -237,6 +245,7 @@ export async function executeInboxTask(taskId: string): Promise { ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), + ...(entitlements.length > 0 ? { entitlements } : {}), ...(fileAttachments.length > 0 ? { fileAttachments } : {}), } diff --git a/apps/sim/lib/pptx-renderer/core/viewer.ts b/apps/sim/lib/pptx-renderer/core/viewer.ts index efdb5ac5e02..3434a8ba225 100644 --- a/apps/sim/lib/pptx-renderer/core/viewer.ts +++ b/apps/sim/lib/pptx-renderer/core/viewer.ts @@ -1,11 +1,11 @@ import { getErrorMessage } from '@sim/utils/errors' import type { ECharts } from 'echarts' +import { isAllowedExternalUrl } from '@/lib/core/security/url-safety' import { buildPresentation, type PresentationData } from '../model/presentation' import type { ZipParseLimits } from '../parser/zip-parser' import { parseZip } from '../parser/zip-parser' import type { SlideHandle } from '../renderer/slide-renderer' import { renderSlide as renderSlideInternal } from '../renderer/slide-renderer' -import { isAllowedExternalUrl } from '../utils/url-safety' export type { SlideHandle } from '../renderer/slide-renderer' diff --git a/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts b/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts index 58ed7f9c70d..db73643b419 100644 --- a/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts +++ b/apps/sim/lib/pptx-renderer/renderer/shape-renderer.ts @@ -15,6 +15,7 @@ function hasVisibleText(textBody: TextBody): boolean { return false } +import { isAllowedExternalUrl } from '@/lib/core/security/url-safety' import { emuToPx } from '../parser/units' import type { SafeXmlNode } from '../parser/xml-parser' import { renderCustomGeometry } from '../shapes/custom-geometry' @@ -26,7 +27,6 @@ import { } from '../shapes/presets' import { applyTint, hexToRgb, rgbToHex } from '../utils/color' import { getOrCreateBlobUrl, resolveMediaPath } from '../utils/media' -import { isAllowedExternalUrl } from '../utils/url-safety' import { resolveColor, resolveColorToCss, diff --git a/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts b/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts index e66a802bfcd..42679dcf9e3 100644 --- a/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts +++ b/apps/sim/lib/pptx-renderer/renderer/text-renderer.ts @@ -4,11 +4,11 @@ */ import { hexToRgb, toCssColor } from '@/lib/colors' +import { isAllowedExternalUrl } from '@/lib/core/security/url-safety' import type { PlaceholderInfo } from '../model/nodes/base-node' import type { TextBody } from '../model/nodes/shape-node' import { angleToDeg, emuToPx, pctToDecimal } from '../parser/units' import { SafeXmlNode } from '../parser/xml-parser' -import { isAllowedExternalUrl } from '../utils/url-safety' import type { RenderContext } from './render-context' import { resolveColor, resolveColorToCss } from './style-resolver' diff --git a/apps/sim/lib/pptx-renderer/utils/url-safety.test.ts b/apps/sim/lib/pptx-renderer/utils/url-safety.test.ts deleted file mode 100644 index 793ae18ea5b..00000000000 --- a/apps/sim/lib/pptx-renderer/utils/url-safety.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isAllowedExternalUrl } from '@/lib/pptx-renderer/utils/url-safety' - -describe('isAllowedExternalUrl', () => { - it('allows http, https, and mailto URLs', () => { - expect(isAllowedExternalUrl('https://example.com/deck')).toBe(true) - expect(isAllowedExternalUrl('http://example.com/deck')).toBe(true) - expect(isAllowedExternalUrl('mailto:support@example.com')).toBe(true) - }) - - it('rejects scriptable, data, and relative URLs', () => { - expect(isAllowedExternalUrl('javascript:alert(1)')).toBe(false) - expect(isAllowedExternalUrl('data:text/html,')).toBe(false) - expect(isAllowedExternalUrl('/workspace/files')).toBe(false) - }) -}) diff --git a/apps/sim/lib/pptx-renderer/utils/url-safety.ts b/apps/sim/lib/pptx-renderer/utils/url-safety.ts deleted file mode 100644 index c3e8f42b2de..00000000000 --- a/apps/sim/lib/pptx-renderer/utils/url-safety.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * URL safety utilities for external hyperlinks/media in untrusted PPTX content. - */ - -const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']) - -/** - * Returns true only for absolute URLs with an allowed protocol. - */ -export function isAllowedExternalUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase()) - } catch { - return false - } -} diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 23e602b83d1..643867dd611 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -25,15 +25,36 @@ const logger = createLogger('CustomBlocksOperations') * enterprise plan). Applying it in every org-scoped resolver keeps execution, the * copilot VFS, and workspace context from surfacing blocks the API withholds (e.g. * after an org drops off the enterprise plan). Returns `null` when ineligible. + * + * Pass `userId` when the caller acts for a specific user so per-user flag + * targeting matches the REST routes; workspace-scoped resolvers (VFS, context, + * executor overlay) omit it and evaluate at org level. */ -async function eligibleOrgForWorkspace(workspaceId: string): Promise { +async function eligibleOrgForWorkspace( + workspaceId: string, + userId?: string +): Promise { const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) if (!ws?.organizationId) return null - if (!(await isFeatureEnabled('deploy-as-block', { orgId: ws.organizationId }))) return null + if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ws.organizationId }))) { + return null + } if (!(await isOrganizationOnEnterprisePlan(ws.organizationId))) return null return ws.organizationId } +/** + * Whether the workspace's org may use custom blocks (`deploy-as-block` flag + + * enterprise plan). Feeds the 'custom-blocks' entitlement in + * `@/lib/copilot/entitlements` and matches the REST route gates. + */ +export async function isCustomBlocksEligible( + workspaceId: string, + userId?: string +): Promise { + return (await eligibleOrgForWorkspace(workspaceId, userId)) !== null +} + /** A persisted custom block plus its live-derived Start input fields. */ export interface CustomBlockWithInputs { id: string @@ -170,6 +191,37 @@ export async function listCustomBlockSummariesForWorkspace( .where(and(eq(customBlock.organizationId, organizationId), eq(customBlock.enabled, true))) } +/** + * Hydrate a joined custom-block row into the wire shape. Field set derived live + * from the deployed Start; stored placeholders merged in. Derive even for a + * disabled block — the source workflow's deployment is independent of the block's + * enabled flag, and the edit form needs the real fields so a save doesn't + * overwrite the block's stored placeholders. + */ +async function hydrateCustomBlockRow(joined: { + block: typeof customBlock.$inferSelect + workflowName: string + workspaceId: string | null + workspaceName: string | null +}): Promise { + const { block: row, workflowName, workspaceId, workspaceName } = joined + return { + id: row.id, + organizationId: row.organizationId, + workflowId: row.workflowId, + workflowName, + workspaceId, + workspaceName, + type: row.type, + name: row.name, + description: row.description, + iconUrl: row.iconUrl, + enabled: row.enabled, + inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)), + exposedOutputs: row.outputs ?? [], + } +} + /** The org's custom blocks with live-derived input fields (client overlay + list API). */ export async function listCustomBlocksWithInputs( organizationId: string @@ -186,27 +238,30 @@ export async function listCustomBlocksWithInputs( .leftJoin(workspace, eq(workspace.id, workflow.workspaceId)) .where(eq(customBlock.organizationId, organizationId)) - return Promise.all( - rows.map(async ({ block: row, workflowName, workspaceId, workspaceName }) => ({ - id: row.id, - organizationId: row.organizationId, - workflowId: row.workflowId, - workflowName, - workspaceId, - workspaceName, - type: row.type, - name: row.name, - description: row.description, - iconUrl: row.iconUrl, - enabled: row.enabled, - // Field set derived live from the deployed Start; stored placeholders merged - // in. Derive even for a disabled block — the source workflow's deployment is - // independent of the block's enabled flag, and the edit form needs the real - // fields so a save doesn't overwrite the block's stored placeholders. - inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)), - exposedOutputs: row.outputs ?? [], - })) - ) + return Promise.all(rows.map(hydrateCustomBlockRow)) +} + +/** + * The custom block bound to a workflow (with live-derived input fields), or `null` + * when the workflow isn't published as a block. One block per workflow is enforced + * at publish time. Used by the copilot deploy_custom_block tool. + */ +export async function getCustomBlockWithInputsByWorkflowId( + workflowId: string +): Promise { + const [row] = await db + .select({ + block: customBlock, + workflowName: workflow.name, + workspaceId: workflow.workspaceId, + workspaceName: workspace.name, + }) + .from(customBlock) + .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) + .leftJoin(workspace, eq(workspace.id, workflow.workspaceId)) + .where(eq(customBlock.workflowId, workflowId)) + .limit(1) + return row ? hydrateCustomBlockRow(row) : null } /** Fetch a single custom block row by id. */ diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index c11bcd6cc2c..845241d3cd6 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -29,6 +29,10 @@ const minimalRegistryAlias: Record = useMinimalRegistry const nextConfig: NextConfig = { devIndicators: false, poweredByHeader: false, + // Safe here since this repo's source is already fully public on GitHub - + // no additional exposure versus Next's default (disabled to avoid leaking + // source on the client). + productionBrowserSourceMaps: true, turbopack: { root: path.join(import.meta.dirname, '../..'), resolveAlias: minimalRegistryAlias, @@ -188,7 +192,11 @@ const nextConfig: NextConfig = { async headers() { return [ { - source: '/((?!api/).*\\.(?:svg|jpg|jpeg|png|gif|ico|webp|avif|woff|woff2|ttf|eot))', + // `/public`-served assets keep their path across deploys (no content + // hash), so a shorter TTL + revalidation window bounds how long a + // changed asset can serve stale. + source: + '/((?!api/|_next/static/).*\\.(?:svg|jpg|jpeg|png|gif|ico|webp|avif|woff|woff2|ttf|eot))', headers: [ { key: 'Cache-Control', @@ -245,14 +253,29 @@ const nextConfig: NextConfig = { }, ], }, - // Block access to sourcemap files (defense in depth) + // Block access to sourcemap files (defense in depth). The trailing + // `$` this rule previously ended with is not a regex anchor in Next's + // `source` matcher (path-to-regexp syntax, not raw regex) - it matched + // a literal `$` character, so this rule never actually fired against + // real `.map` URLs. Next already anchors the compiled pattern at both + // ends, so no trailing anchor is needed here. + // + // Also bounds `.map` files to a short, revalidated TTL rather than + // Next's built-in 1yr immutable default for `_next/static/*` - maps + // are content-hashed like their JS, so this isn't about staleness, + // it's so a future decision to stop shipping `productionBrowserSourceMaps` + // isn't undermined by browsers/edges holding old maps for a year. { - source: '/(.*)\\.map$', + source: '/(.*)\\.map', headers: [ { key: 'x-robots-tag', value: 'noindex', }, + { + key: 'Cache-Control', + value: 'public, max-age=86400, stale-while-revalidate=604800', + }, ], }, // Chat pages - allow iframe embedding from any origin diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 0c01133649c..14dcee48590 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -218,3 +218,17 @@ describe('zai provider definition', () => { expect(getHostedModels()).toContain('glm-4.6') }) }) + +describe('xai provider definition', () => { + const xai = PROVIDER_DEFINITIONS.xai + + it('is registered with grok-4.5 as the default model', () => { + expect(xai).toBeDefined() + expect(xai.id).toBe('xai') + expect(xai.defaultModel).toBe('grok-4.5') + }) + + it('is included in getHostedModels since Sim provides the xAI key server-side', () => { + expect(getHostedModels()).toContain('grok-4.5') + }) +}) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index e5021a4c721..bc5aa24cbe7 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -3921,6 +3921,7 @@ export function getHostedModels(): string[] { ...getProviderModels('anthropic'), ...getProviderModels('google'), ...getProviderModels('zai'), + ...getProviderModels('xai'), ] } diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 1e9c877a976..1697c12f80d 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -865,7 +865,7 @@ describe('Cost Calculation', () => { }) describe('getHostedModels', () => { - it.concurrent('should return OpenAI, Anthropic, and Google models as hosted', () => { + it.concurrent('should return OpenAI, Anthropic, Google, and xAI models as hosted', () => { const hostedModels = getHostedModels() expect(hostedModels).toContain('gpt-4o') @@ -877,8 +877,9 @@ describe('getHostedModels', () => { expect(hostedModels).toContain('gemini-2.5-pro') expect(hostedModels).toContain('gemini-2.5-flash') + expect(hostedModels).toContain('grok-4.5') + expect(hostedModels).not.toContain('deepseek-v3') - expect(hostedModels).not.toContain('grok-4-latest') }) it.concurrent('should return an array of strings', () => { @@ -902,11 +903,12 @@ describe('shouldBillModelUsage', () => { expect(shouldBillModelUsage('gemini-2.5-pro')).toBe(true) expect(shouldBillModelUsage('gemini-2.5-flash')).toBe(true) + + expect(shouldBillModelUsage('grok-4.5')).toBe(true) }) it.concurrent('should return false for non-hosted models', () => { expect(shouldBillModelUsage('deepseek-v3')).toBe(false) - expect(shouldBillModelUsage('grok-4-latest')).toBe(false) expect(shouldBillModelUsage('unknown-model')).toBe(false) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 3b8bdd10c6a..8ca3017354a 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -903,8 +903,9 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' const isZaiModel = provider === 'zai' + const isXaiModel = provider === 'xai' - if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel)) { + if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel)) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/public/blog/mothership/cover.jpg b/apps/sim/public/blog/mothership/cover.jpg index cc26d327547..598ade580b9 100644 Binary files a/apps/sim/public/blog/mothership/cover.jpg and b/apps/sim/public/blog/mothership/cover.jpg differ diff --git a/apps/sim/public/blog/mothership/cover.png b/apps/sim/public/blog/mothership/cover.png deleted file mode 100644 index e4410a5cb69..00000000000 Binary files a/apps/sim/public/blog/mothership/cover.png and /dev/null differ diff --git a/apps/sim/public/llms.txt b/apps/sim/public/llms.txt deleted file mode 100644 index 723c554eec0..00000000000 --- a/apps/sim/public/llms.txt +++ /dev/null @@ -1,59 +0,0 @@ -# Sim - -Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Create agents visually with the workflow builder, conversationally through Chat, or programmatically with the API — connected to 1,000+ integrations and every major LLM. - -## Key Facts - -- Website: https://sim.ai -- GitHub: https://github.com/simstudioai/sim -- Documentation: https://docs.sim.ai -- License: Apache 2.0 -- Category: AI workspace, AI agent builder, developer tools -- Trusted by: 100,000+ builders, from startups to Fortune 500 - -## What Sim Does - -Sim is a unified AI workspace with these core modules: - -- **Mothership** — AI command center. Build and manage everything in natural language. -- **Workflows** — Visual builder. Connect blocks, models, and integrations into agent logic. -- **Knowledge Base** — Upload docs, sync sources, build vector databases for agent memory. -- **Tables** — Built-in database. Store, query, and wire structured data into agent runs. -- **Files** — Upload, create, and share documents across your team and agents. -- **Logs** — Full execution tracing. Inputs, outputs, cost, and duration for every run. - -## Capabilities - -- 1,000+ integrations (Slack, Gmail, GitHub, Notion, Jira, Salesforce, HubSpot, and more) -- Every major LLM: OpenAI, Anthropic, Google Gemini, xAI Grok, Mistral, Groq, Cerebras -- Real-time team collaboration with multiplayer editing -- Enterprise governance: RBAC, staging/production environments, deployment versioning, audit logs -- Self-hosting via Docker, bring-your-own-key (BYOK) for all model providers -- API, CLI, and SDK access (TypeScript, Python) -- MCP (Model Context Protocol) server creation and connection -- SOC2 compliant - -## Key Pages - -- AI Models Directory: https://sim.ai/models -- Integrations: https://sim.ai/integrations -- Pricing: https://sim.ai/#pricing -- Partners: https://sim.ai/partners - -## Blog - -The Sim blog covers announcements, technical deep-dives, and guides for building AI agents. - -- Blog: https://sim.ai/blog -- RSS: https://sim.ai/blog/rss.xml - -## Documentation - -- Getting Started: https://docs.sim.ai/getting-started -- Blocks: https://docs.sim.ai/workflows -- Tools & Integrations: https://docs.sim.ai/integrations -- Webhooks: https://docs.sim.ai/workflows/triggers/webhook -- MCP Protocol: https://docs.sim.ai/agents/mcp -- Self-Hosting: https://docs.sim.ai/platform/self-hosting -- API Reference: https://docs.sim.ai/api-reference/getting-started -- SDKs: https://docs.sim.ai/api-reference diff --git a/apps/sim/public/logo/426-240/reverse/small.png b/apps/sim/public/logo/426-240/reverse/small.png index 196dd6389e9..3e0f96d5b91 100644 Binary files a/apps/sim/public/logo/426-240/reverse/small.png and b/apps/sim/public/logo/426-240/reverse/small.png differ diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 10808b860b0..65a84b2d627 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -67,18 +67,24 @@ export const useSearchModalStore = create()( devtools( (set, _) => ({ isOpen: false, + sections: null, + pendingConnect: null, data: initialData, setOpen: (open: boolean) => { - set({ isOpen: open }) + set({ isOpen: open, sections: null, pendingConnect: null }) }, - open: () => { - set({ isOpen: true }) + open: (options) => { + set({ + isOpen: true, + sections: options?.sections ?? null, + pendingConnect: options?.pendingConnect ?? null, + }) }, close: () => { - set({ isOpen: false }) + set({ isOpen: false, sections: null, pendingConnect: null }) }, initializeData: (filterBlocks) => { diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index b276f23627a..b8315e8156e 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -49,6 +49,44 @@ export interface SearchData { isInitialized: boolean } +/** + * Every result group the search modal can render, in render order. Used to + * restrict the palette to a subset of sections when opened for a specific + * intent (e.g. a drag-release that should only offer canvas-insertable items). + */ +export const SEARCH_SECTIONS = [ + 'actions', + 'connectedAccounts', + 'integrations', + 'blocks', + 'tools', + 'triggers', + 'chats', + 'workflows', + 'tables', + 'files', + 'knowledgeBases', + 'toolOperations', + 'workspaces', + 'docs', + 'pages', +] as const + +/** A single search-modal result group. */ +export type SearchSection = (typeof SEARCH_SECTIONS)[number] + +/** + * Context handed to the palette when it is opened to complete an edge + * drag-release: the dragged source handle and the release point. A selection + * stamps it onto its event so the canvas places the block at the drop point and + * wires it from that handle. + */ +export interface PendingConnect { + source: { nodeId: string; handleId: string } + screenX: number + screenY: number +} + /** * Global state for the universal search modal. * @@ -60,18 +98,33 @@ export interface SearchModalState { /** Whether the search modal is currently open. */ isOpen: boolean + /** + * When set, the palette renders only these sections; `null` shows all of them. + */ + sections: SearchSection[] | null + + /** + * Pending edge drag-release the palette was opened to complete. A selection + * stamps it onto its event; other add-block dispatchers carry none, so only a + * genuine palette pick completes the connection. `null` for ordinary opens. + */ + pendingConnect: PendingConnect | null + /** Pre-computed search data. */ data: SearchData /** - * Explicitly set the open state of the modal. + * Explicitly set the open state of the modal. Always resets to the full + * palette (no section restriction, no pending connect). */ setOpen: (open: boolean) => void /** - * Convenience method to open the modal. + * Convenience method to open the modal. Pass `sections` to restrict the + * palette to a subset of result groups, and `pendingConnect` to complete an + * edge drag-release with the selection. */ - open: () => void + open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void /** * Convenience method to close the modal. diff --git a/apps/sim/tools/brex/get_company.ts b/apps/sim/tools/brex/get_company.ts index 700e339a089..5e222e387c1 100644 --- a/apps/sim/tools/brex/get_company.ts +++ b/apps/sim/tools/brex/get_company.ts @@ -31,7 +31,7 @@ export const brexGetCompanyTool: ToolConfig = ({ if (total === 0) return null return ( - - - - {title ? ( - - {Icon ? : null} - {title} - - ) : ( - activeStep?.props.title - )} - - - - - {description ?? 'Multi-step wizard'} - - {activeStep} - - - - - {isLast ? ( - - ) : ( - - )} - - - + div]` variants stretch ChipModal's inner content column so the + // body fills the fixed height instead of leaving a gap; only applied when a + // height is set, so other ChipModal consumers are untouched. + className={height ? cn(height, '[&>div]:min-h-0 [&>div]:flex-1') : undefined} + > + onOpenChange(false)}> + {title ?? activeStep?.props.title} + + + + + {description ?? 'Multi-step wizard'} + + {activeStep} + + + onOpenChange(false)} + hideCancel + primaryAdjacentAction={{ label: backLabel, onClick: handleBack, disabled: clamped === 0 }} + primaryAction={ + isLast + ? { label: doneLabel, onClick: handleDone } + : { label: nextLabel, onClick: handleNext, disabled: !canAdvance } + } + /> + ) } diff --git a/packages/workflow-renderer/src/lib/overflow-span.tsx b/packages/workflow-renderer/src/lib/overflow-span.tsx new file mode 100644 index 00000000000..65437f0d8a5 --- /dev/null +++ b/packages/workflow-renderer/src/lib/overflow-span.tsx @@ -0,0 +1,25 @@ +import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn' + +interface OverflowSpanProps { + value: string + className: string +} + +/** + * Truncated span that reveals its full value in a floating tooltip when — and + * only when — the text is actually clipped. Never use a native `title` + * attribute here: on the canvas it pops the browser's raw, unstyled tooltip + * with the full untruncated value (including raw code/JSON) over the graph. + */ +export function OverflowSpan({ value, className }: OverflowSpanProps) { + const { state, handlers } = useFloatingTooltip(isTextClipped) + + return ( + <> + + {value} + + + + ) +} diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx index 01930cdcac1..7be0b0bb7b6 100644 --- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx @@ -1,4 +1,5 @@ import { cn } from '@sim/emcn' +import { OverflowSpan } from '../lib/overflow-span' /** * Props for the pure subblock summary row. The container resolves the value — @@ -22,22 +23,18 @@ export interface SubBlockRowViewProps { export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRowViewProps) { return (
- - {title} - + /> {displayValue !== undefined && ( - - {displayValue} - + /> )}
) diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 3b7dbb36cec..cf43c63796d 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -2,6 +2,7 @@ import type { ComponentType, ReactNode, Ref } from 'react' import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn' import { Handle, Position } from 'reactflow' import { HANDLE_POSITIONS } from '../dimensions' +import { OverflowSpan } from '../lib/overflow-span' import { tileIconColorClass } from '../lib/tile-icon-color' import type { BlockRunStatus } from '../types' import { SubBlockRowView } from './sub-block-row-view' @@ -212,15 +213,13 @@ export function WorkflowBlockView({ )} /> - - {name} - + />
{isWorkflowSelector &&