Compare commits

...

43 Commits

Author SHA1 Message Date
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Waleed
376f7cb571 fix(auth): add genericOAuth providers to trustedProviders (#2937) 2026-01-21 22:44:30 -08:00
Vikhyath Mondreti
42159c23b9 improvement(copilot): tool configs to show nested props (#2936) 2026-01-21 20:02:59 -08:00
Emir Karabeg
2f0f246002 feat(workflow-block): preview (#2935) 2026-01-21 19:12:28 -08:00
Waleed
900d3ef9ea fix(workflow-selector): use dedicated selector for workflow dropdown (#2934) 2026-01-21 18:38:03 -08:00
Waleed
f3fcc28f89 fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)
* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication
2026-01-21 18:34:49 -08:00
Vikhyath Mondreti
7cfdf46724 fix(token-refresh): microsoft, notion, x, linear (#2933)
* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment
2026-01-21 18:30:53 -08:00
Vikhyath Mondreti
d681451297 fix(null-bodies): empty bodies handling (#2931)
* fix(null-statuses): empty bodies handling

* address bugbot comment
2026-01-21 18:10:33 -08:00
Waleed
5987a6d060 fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)
* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.
2026-01-21 17:36:10 -08:00
Waleed
e2ccefb2f4 improvement(ui): use BrandedButton and BrandedLink components (#2930)
- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value
2026-01-21 17:25:30 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
38 changed files with 611 additions and 417 deletions

View File

@@ -2,10 +2,9 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -22,6 +21,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
@@ -107,7 +107,6 @@ export default function LoginPage({
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [callbackUrl, setCallbackUrl] = useState('/workspace')
const [isInviteFlow, setIsInviteFlow] = useState(false)
@@ -115,7 +114,6 @@ export default function LoginPage({
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
const [resetStatus, setResetStatus] = useState<{
type: 'success' | 'error' | null
message: string
@@ -184,6 +182,13 @@ export default function LoginPage({
e.preventDefault()
setIsLoading(true)
const redirectToVerify = (emailToVerify: string) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailToVerify)
}
router.push('/verify')
}
const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string
const email = emailRaw.trim().toLowerCase()
@@ -215,9 +220,9 @@ export default function LoginPage({
onError: (ctx) => {
logger.error('Login error:', ctx.error)
// EMAIL_NOT_VERIFIED is handled by the catch block which redirects to /verify
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
errorHandled = true
redirectToVerify(email)
return
}
@@ -285,10 +290,7 @@ export default function LoginPage({
router.push(safeCallbackUrl)
} catch (err: any) {
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', email)
}
router.push('/verify')
redirectToVerify(email)
return
}
@@ -491,24 +493,14 @@ export default function LoginPage({
</div>
</div>
<Button
<BrandedButton
type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isLoading}
loading={isLoading}
loadingText='Signing in'
>
<span className='flex items-center gap-1'>
{isLoading ? 'Signing in...' : 'Sign in'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
Sign in
</BrandedButton>
</form>
)}
@@ -619,25 +611,15 @@ export default function LoginPage({
<p>{resetStatus.message}</p>
</div>
)}
<Button
<BrandedButton
type='button'
onClick={handleForgotPassword}
onMouseEnter={() => setIsResetButtonHovered(true)}
onMouseLeave={() => setIsResetButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isSubmittingReset}
loading={isSubmittingReset}
loadingText='Sending'
>
<span className='flex items-center gap-1'>
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isResetButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
Send Reset Link
</BrandedButton>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,13 +1,12 @@
'use client'
import { useState } from 'react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
interface RequestResetFormProps {
email: string
@@ -28,9 +27,6 @@ export function RequestResetForm({
statusMessage,
className,
}: RequestResetFormProps) {
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
onSubmit(email)
@@ -68,24 +64,14 @@ export function RequestResetForm({
)}
</div>
<Button
<BrandedButton
type='submit'
disabled={isSubmitting}
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
loading={isSubmitting}
loadingText='Sending'
>
<span className='flex items-center gap-1'>
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
Send Reset Link
</BrandedButton>
</form>
)
}
@@ -112,8 +98,6 @@ export function SetNewPasswordForm({
const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -243,24 +227,14 @@ export function SetNewPasswordForm({
)}
</div>
<Button
disabled={isSubmitting || !token}
<BrandedButton
type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isSubmitting || !token}
loading={isSubmitting}
loadingText='Resetting'
>
<span className='flex items-center gap-1'>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
Reset Password
</BrandedButton>
</form>
)
}

View File

@@ -2,10 +2,9 @@
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client, useSession } from '@/lib/auth/auth-client'
@@ -14,6 +13,7 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
@@ -97,7 +97,6 @@ function SignupFormContent({
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
@@ -476,24 +475,14 @@ function SignupFormContent({
</div>
</div>
<Button
<BrandedButton
type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isLoading}
loading={isLoading}
loadingText='Creating account'
>
<span className='flex items-center gap-1'>
{isLoading ? 'Creating account' : 'Create account'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
Create account
</BrandedButton>
</form>
)}

View File

@@ -4,7 +4,6 @@ import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Textarea } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -18,6 +17,7 @@ import { isHosted } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import Footer from '@/app/(landing)/components/footer/footer'
import Nav from '@/app/(landing)/components/nav/nav'
@@ -493,18 +493,17 @@ export default function CareersPage() {
{/* Submit Button */}
<div className='flex justify-end pt-2'>
<Button
<BrandedButton
type='submit'
disabled={isSubmitting || submitStatus === 'success'}
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
size='lg'
loading={isSubmitting}
loadingText='Submitting'
showArrow={false}
fullWidth={false}
className='min-w-[200px]'
>
{isSubmitting
? 'Submitting...'
: submitStatus === 'success'
? 'Submitted'
: 'Submit Application'}
</Button>
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
</BrandedButton>
</div>
</form>
</section>

View File

@@ -11,6 +11,7 @@ import { useBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('nav')
@@ -20,11 +21,12 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('25.1k')
const [githubStars, setGithubStars] = useState('25.8k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()
const brand = useBrandConfig()
const buttonClass = useBrandedButtonClass()
useEffect(() => {
if (variant !== 'landing') return
@@ -183,7 +185,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>

View File

@@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
const logger = createLogger('OAuthUtilsAPI')
@@ -205,15 +210,32 @@ export async function refreshAccessTokenIfNeeded(
}
// Decide if we should refresh: token missing OR expired
const expiresAt = credential.accessTokenExpiresAt
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
const shouldRefresh =
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
const accessToken = credential.accessToken
if (shouldRefresh) {
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
logger.info(`[${requestId}] Refreshing token for credential`)
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
@@ -227,11 +249,15 @@ export async function refreshAccessTokenIfNeeded(
userId: credential.userId,
hasRefreshToken: !!credential.refreshToken,
})
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null
}
// Prepare update data
const updateData: any = {
const updateData: Record<string, unknown> = {
accessToken: refreshedToken.accessToken,
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
updatedAt: new Date(),
@@ -243,6 +269,10 @@ export async function refreshAccessTokenIfNeeded(
updateData.refreshToken = refreshedToken.refreshToken
}
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
// Update the token in the database
await db.update(account).set(updateData).where(eq(account.id, credentialId))
@@ -256,6 +286,10 @@ export async function refreshAccessTokenIfNeeded(
credentialId,
userId: credential.userId,
})
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null
}
} else if (!accessToken) {
@@ -277,10 +311,27 @@ export async function refreshTokenIfNeeded(
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
// Decide if we should refresh: token missing OR expired
const expiresAt = credential.accessTokenExpiresAt
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const now = new Date()
const shouldRefresh =
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
// If token appears valid and present, return it directly
if (!shouldRefresh) {
@@ -293,13 +344,17 @@ export async function refreshTokenIfNeeded(
if (!refreshResult) {
logger.error(`[${requestId}] Failed to refresh token for credential`)
if (!accessTokenNeedsRefresh && credential.accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return { accessToken: credential.accessToken, refreshed: false }
}
throw new Error('Failed to refresh token')
}
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
// Prepare update data
const updateData: any = {
const updateData: Record<string, unknown> = {
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
updatedAt: new Date(),
@@ -311,6 +366,10 @@ export async function refreshTokenIfNeeded(
updateData.refreshToken = newRefreshToken
}
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
@@ -331,6 +390,11 @@ export async function refreshTokenIfNeeded(
}
}
if (!accessTokenNeedsRefresh && credential.accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return { accessToken: credential.accessToken, refreshed: false }
}
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
throw error
}

View File

@@ -0,0 +1,27 @@
'use client'
import Link from 'next/link'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface BrandedLinkProps {
href: string
children: React.ReactNode
className?: string
target?: string
rel?: string
}
export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) {
const buttonClass = useBrandedButtonClass()
return (
<Link
href={href}
target={target}
rel={rel}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all ${className}`}
>
{children}
</Link>
)
}

View File

@@ -2,6 +2,7 @@ import { BookOpen, Github, Rss } from 'lucide-react'
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedLink } from '@/app/changelog/components/branded-link'
import ChangelogList from '@/app/changelog/components/timeline-list'
export interface ChangelogEntry {
@@ -66,25 +67,24 @@ export default async function ChangelogContent() {
<hr className='mt-6 border-border' />
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
<Link
<BrandedLink
href='https://github.com/simstudioai/sim/releases'
target='_blank'
rel='noopener noreferrer'
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
>
<Github className='h-4 w-4' />
View on GitHub
</Link>
</BrandedLink>
<Link
href='https://docs.sim.ai'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
>
<Rss className='h-4 w-4' />
RSS Feed

View File

@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const [starCount, setStarCount] = useState('25.1k')
const [starCount, setStarCount] = useState('25.8k')
const [conversationId, setConversationId] = useState('')
const [showScrollButton, setShowScrollButton] = useState(false)

View File

@@ -207,7 +207,6 @@ function TemplateCardInner({
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
) : (
<div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -16,8 +16,8 @@ import {
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
BlockDetailsSidebar,
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
@@ -248,11 +248,10 @@ export function ExecutionSnapshot({
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
lightweight
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<BlockDetailsSidebar
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}

View File

@@ -213,7 +213,6 @@ function TemplateCardInner({
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
cursorStyle='pointer'
/>
) : (

View File

@@ -18,8 +18,8 @@ import {
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import {
BlockDetailsSidebar,
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
@@ -337,7 +337,7 @@ export function GeneralDeploy({
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<BlockDetailsSidebar
<PreviewEditor
block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops}

View File

@@ -446,7 +446,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)

View File

@@ -34,3 +34,4 @@ export { Text } from './text/text'
export { TimeInput } from './time-input/time-input'
export { ToolInput } from './tool-input/tool-input'
export { VariablesInput } from './variables-input/variables-input'
export { WorkflowSelectorInput } from './workflow-selector/workflow-selector-input'

View File

@@ -2,12 +2,13 @@ import { useMemo, useRef, useState } from 'react'
import { Badge, Input } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWorkflowInputFields } from '@/hooks/queries/workflows'
import { useWorkflowState } from '@/hooks/queries/workflows'
/**
* Props for the InputMappingField component
@@ -70,7 +71,11 @@ export function InputMapping({
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
const { data: childInputFields = [], isLoading } = useWorkflowInputFields(workflowId)
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
const childInputFields = useMemo(
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
[workflowState?.blocks]
)
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
const valueObj: Record<string, string> = useMemo(() => {

View File

@@ -29,6 +29,7 @@ import {
type OAuthProvider,
type OAuthService,
} from '@/lib/oauth'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
CheckboxList,
@@ -65,7 +66,7 @@ import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hoo
import {
useChildDeploymentStatus,
useDeployChildWorkflow,
useWorkflowInputFields,
useWorkflowState,
useWorkflows,
} from '@/hooks/queries/workflows'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -771,7 +772,11 @@ function WorkflowInputMapperSyncWrapper({
disabled: boolean
workflowId: string
}) {
const { data: inputFields = [], isLoading } = useWorkflowInputFields(workflowId)
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
const inputFields = useMemo(
() => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []),
[workflowState?.blocks]
)
const parsedValue = useMemo(() => {
try {

View File

@@ -0,0 +1,45 @@
'use client'
import { useMemo } from 'react'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface WorkflowSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
}
export function WorkflowSelectorInput({
blockId,
subBlock,
disabled = false,
isPreview = false,
previewValue,
}: WorkflowSelectorInputProps) {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const context: SelectorContext = useMemo(
() => ({
excludeWorkflowId: activeWorkflowId ?? undefined,
}),
[activeWorkflowId]
)
return (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='sim.workflows'
selectorContext={context}
disabled={disabled}
isPreview={isPreview}
previewValue={previewValue}
placeholder={subBlock.placeholder || 'Select workflow...'}
/>
)
}

View File

@@ -40,6 +40,7 @@ import {
TimeInput,
ToolInput,
VariablesInput,
WorkflowSelectorInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import type { SubBlockConfig } from '@/blocks/types'
@@ -90,7 +91,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
if (!config.required) return false
if (typeof config.required === 'boolean') return config.required
// Helper function to evaluate a condition
const evalCond = (
cond: {
field: string
@@ -132,7 +132,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
return match
}
// If required is a condition object or function, evaluate it
const condition = typeof config.required === 'function' ? config.required() : config.required
return evalCond(condition, subBlockValues || {})
}
@@ -378,7 +377,6 @@ function SubBlockComponent({
setIsValidJson(isValid)
}
// Check if wand is enabled for this sub-block
const isWandEnabled = config.wandConfig?.enabled ?? false
/**
@@ -438,8 +436,6 @@ function SubBlockComponent({
| null
| undefined
// Use dependsOn gating to compute final disabled state
// Only pass previewContextValues when in preview mode to avoid format mismatches
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
disabled,
isPreview,
@@ -869,6 +865,17 @@ function SubBlockComponent({
/>
)
case 'workflow-selector':
return (
<WorkflowSelectorInput
blockId={blockId}
subBlock={config}
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue as string | null}
/>
)
case 'mcp-server-selector':
return (
<McpServerSelector

View File

@@ -68,7 +68,7 @@ export function SubflowEditor({
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Subflow Editor Section */}
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[5px] pb-[8px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[9px] pb-[8px]'>
{/* Type Selection */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>

View File

@@ -2,7 +2,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
import {
BookOpen,
Check,
ChevronDown,
ChevronUp,
ExternalLink,
Loader2,
Pencil,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Button, Tooltip } from '@/components/emcn'
@@ -29,8 +38,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
import { useWorkflowState } from '@/hooks/queries/workflows'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -85,6 +96,14 @@ export function Editor() {
// Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Check if selected block is a workflow block
const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
// Get workspace ID from params
const params = useParams()
const workspaceId = params.workspaceId as string
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -254,11 +273,11 @@ export function Editor() {
// Trigger rename mode when signaled from context menu
useEffect(() => {
if (shouldFocusRename && currentBlock && !isSubflow) {
if (shouldFocusRename && currentBlock) {
handleStartRename()
setShouldFocusRename(false)
}
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
}, [shouldFocusRename, currentBlock, handleStartRename, setShouldFocusRename])
/**
* Handles opening documentation link in a new secure tab.
@@ -270,6 +289,22 @@ export function Editor() {
}
}
// Get child workflow ID for workflow blocks
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
useWorkflowState(childWorkflowId)
/**
* Handles opening the child workflow in a new tab.
*/
const handleOpenChildWorkflow = useCallback(() => {
if (childWorkflowId && workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
}
}, [childWorkflowId, workspaceId])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35
@@ -322,7 +357,7 @@ export function Editor() {
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
{/* Rename button */}
{currentBlock && !isSubflow && (
{currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -408,7 +443,66 @@ export function Editor() {
className='subblocks-section flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
{subBlocks.length === 0 ? (
{/* Workflow Preview - only for workflow blocks with a selected child workflow */}
{isWorkflowBlock && childWorkflowId && (
<>
<div className='subblock-content flex flex-col gap-[9.5px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)] leading-none'>
Workflow Preview
</div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{isLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-tertiary)]' />
</div>
) : childWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<WorkflowPreview
workflowState={childWorkflowState}
height={160}
width='100%'
isPannable={true}
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleOpenChildWorkflow}
className='absolute right-[6px] bottom-[6px] z-10 h-[24px] w-[24px] cursor-pointer border border-[var(--border)] bg-[var(--surface-2)] p-0 hover:bg-[var(--surface-4)]'
>
<ExternalLink className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Open workflow</Tooltip.Content>
</Tooltip.Root>
</>
) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
Unable to load preview
</span>
</div>
)}
</div>
</div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</>
)}
{subBlocks.length === 0 && !isWorkflowBlock ? (
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
This block has no subblocks
</div>

View File

@@ -21,11 +21,9 @@ interface WorkflowPreviewBlockData {
}
/**
* Lightweight block component for workflow previews.
* Renders block header, dummy subblocks skeleton, and handles.
* Respects horizontalHandles and enabled state from workflow.
* No heavy hooks, store subscriptions, or interactive features.
* Used in template cards and other preview contexts for performance.
* Preview block component for workflow visualization.
* Renders block header, subblocks skeleton, and handles without
* hooks, store subscriptions, or interactive features.
*/
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
const {

View File

@@ -685,7 +685,7 @@ interface WorkflowVariable {
value: unknown
}
interface BlockDetailsSidebarProps {
interface PreviewEditorProps {
block: BlockState
executionData?: ExecutionData
/** All block execution data for resolving variable references */
@@ -722,7 +722,7 @@ const DEFAULT_CONNECTIONS_HEIGHT = 150
/**
* Readonly sidebar panel showing block configuration using SubBlock components.
*/
function BlockDetailsSidebarContent({
function PreviewEditorContent({
block,
executionData,
allBlockExecutions,
@@ -732,7 +732,7 @@ function BlockDetailsSidebarContent({
parallels,
isExecutionMode = false,
onClose,
}: BlockDetailsSidebarProps) {
}: PreviewEditorProps) {
// Convert Record<string, Variable> to Array<Variable> for iteration
const normalizedWorkflowVariables = useMemo(() => {
if (!workflowVariables) return []
@@ -998,6 +998,22 @@ function BlockDetailsSidebarContent({
})
}, [extractedRefs.envVars])
const rawValues = useMemo(() => {
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
if (entry && typeof entry === 'object' && 'value' in entry) {
acc[key] = (entry as { value: unknown }).value
} else {
acc[key] = entry
}
return acc
}, {})
}, [subBlockValues])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
// Check if this is a subflow block (loop or parallel)
const isSubflow = block.type === 'loop' || block.type === 'parallel'
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
@@ -1079,21 +1095,6 @@ function BlockDetailsSidebarContent({
)
}
const rawValues = useMemo(() => {
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
if (entry && typeof entry === 'object' && 'value' in entry) {
acc[key] = (entry as { value: unknown }).value
} else {
acc[key] = entry
}
return acc
}, {})
}, [subBlockValues])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig.subBlocks),
[blockConfig.subBlocks]
)
const canonicalModeOverrides = block.data?.canonicalModes
const effectiveAdvanced =
(block.advancedMode ?? false) ||
@@ -1371,12 +1372,12 @@ function BlockDetailsSidebarContent({
}
/**
* Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
* Preview editor wrapped in ReactFlowProvider for hook compatibility.
*/
export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
export function PreviewEditor(props: PreviewEditorProps) {
return (
<ReactFlowProvider>
<BlockDetailsSidebarContent {...props} />
<PreviewEditorContent {...props} />
</ReactFlowProvider>
)
}

View File

@@ -15,10 +15,9 @@ interface WorkflowPreviewSubflowData {
}
/**
* Lightweight subflow component for workflow previews.
* Matches the styling of the actual SubflowNodeComponent but without
* hooks, store subscriptions, or interactive features.
* Used in template cards and other preview contexts for performance.
* Preview subflow component for workflow visualization.
* Renders loop/parallel containers without hooks, store subscriptions,
* or interactive features.
*/
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data

View File

@@ -1,2 +1,2 @@
export { BlockDetailsSidebar } from './components/block-details-sidebar'
export { PreviewEditor } from './components/preview-editor'
export { getLeftmostBlockId, WorkflowPreview } from './preview'

View File

@@ -15,14 +15,10 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger'
import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
import { getBlock } from '@/blocks'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview')
@@ -134,8 +130,6 @@ interface WorkflowPreviewProps {
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when the canvas (empty area) is clicked */
onPaneClick?: () => void
/** Use lightweight blocks for better performance in template cards */
lightweight?: boolean
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
@@ -145,19 +139,10 @@ interface WorkflowPreviewProps {
}
/**
* Full node types with interactive WorkflowBlock for detailed previews
* Preview node types using minimal components without hooks or store subscriptions.
* This prevents interaction issues while allowing canvas panning and node clicking.
*/
const fullNodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
noteBlock: NoteBlock,
subflowNode: SubflowNodeComponent,
}
/**
* Lightweight node types for template cards and other high-volume previews.
* Uses minimal components without hooks or store subscriptions.
*/
const lightweightNodeTypes: NodeTypes = {
const previewNodeTypes: NodeTypes = {
workflowBlock: WorkflowPreviewBlock,
noteBlock: WorkflowPreviewBlock,
subflowNode: WorkflowPreviewSubflow,
@@ -172,17 +157,19 @@ const edgeTypes: EdgeTypes = {
interface FitViewOnChangeProps {
nodeIds: string
fitPadding: number
containerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Helper component that calls fitView when the set of nodes changes.
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
* Only triggers on actual node additions/removals, not on selection changes.
* Must be rendered inside ReactFlowProvider.
*/
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null)
// Fit view when nodes change
useEffect(() => {
if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds
@@ -195,6 +182,27 @@ function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView])
// Fit view when container resizes (debounced to avoid excessive calls during drag)
useEffect(() => {
const container = containerRef.current
if (!container) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const resizeObserver = new ResizeObserver(() => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 150 })
}, 100)
})
resizeObserver.observe(container)
return () => {
if (timeoutId) clearTimeout(timeoutId)
resizeObserver.disconnect()
}
}, [containerRef, fitPadding, fitView])
return null
}
@@ -210,15 +218,12 @@ export function WorkflowPreview({
onNodeClick,
onNodeContextMenu,
onPaneClick,
lightweight = false,
cursorStyle = 'grab',
executedBlocks,
selectedBlockId,
}: WorkflowPreviewProps) {
const nodeTypes = useMemo(
() => (lightweight ? lightweightNodeTypes : fullNodeTypes),
[lightweight]
)
const containerRef = useRef<HTMLDivElement>(null)
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
@@ -288,119 +293,28 @@ export function WorkflowPreview({
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
if (lightweight) {
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
draggable: false,
data: {
name: block.name,
width: dimensions.width,
height: dimensions.height,
kind: block.type as 'loop' | 'parallel',
isPreviewSelected: isSelected,
},
})
return
}
const isSelected = selectedBlockId === blockId
let lightweightExecutionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
lightweightExecutionStatus = 'error'
} else if (blockExecution.status === 'success') {
lightweightExecutionStatus = 'success'
} else {
lightweightExecutionStatus = 'not-executed'
}
} else {
lightweightExecutionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
// Blocks inside subflows need higher z-index to appear above the container
zIndex: block.data?.parentId ? 10 : undefined,
data: {
type: block.type,
name: block.name,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus: lightweightExecutionStatus,
},
})
return
}
if (block.type === 'loop') {
// Handle loop/parallel containers
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
name: block.name,
width: dimensions.width,
height: dimensions.height,
state: 'valid',
isPreview: true,
kind: block.type as 'loop' | 'parallel',
isPreviewSelected: isSelected,
kind: 'loop',
},
})
return
}
if (block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
name: block.name,
width: dimensions.width,
height: dimensions.height,
state: 'valid',
isPreview: true,
isPreviewSelected: isSelected,
kind: 'parallel',
},
})
return
}
const blockConfig = getBlock(block.type)
if (!blockConfig) {
logger.error(`No configuration found for block type: ${block.type}`, { blockId })
return
}
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
// Handle regular blocks
const isSelected = selectedBlockId === blockId
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
@@ -418,24 +332,20 @@ export function WorkflowPreview({
}
}
const isSelected = selectedBlockId === blockId
nodeArray.push({
id: blockId,
type: nodeType,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
// Blocks inside subflows need higher z-index to appear above the container
zIndex: block.data?.parentId ? 10 : undefined,
data: {
type: block.type,
config: blockConfig,
name: block.name,
blockState: block,
canEdit: false,
isPreview: true,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
subBlockValues: block.subBlocks ?? {},
executionStatus,
},
})
@@ -448,7 +358,6 @@ export function WorkflowPreview({
parallelsStructure,
workflowState.blocks,
isValidWorkflowState,
lightweight,
executedBlocks,
selectedBlockId,
])
@@ -506,6 +415,7 @@ export function WorkflowPreview({
return (
<ReactFlowProvider>
<div
ref={containerRef}
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
>
@@ -516,6 +426,20 @@ export function WorkflowPreview({
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
/* Active/grabbing cursor when dragging */
${
cursorStyle === 'grab'
? `
.preview-mode .react-flow:active { cursor: grabbing; }
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
`
: ''
}
/* Node cursor - pointer on nodes when onNodeClick is provided */
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
@@ -563,7 +487,11 @@ export function WorkflowPreview({
}
onPaneClick={onPaneClick}
/>
<FitViewOnChange nodeIds={blocksStructure.ids} fitPadding={fitPadding} />
<FitViewOnChange
nodeIds={blocksStructure.ids}
fitPadding={fitPadding}
containerRef={containerRef}
/>
</div>
</ReactFlowProvider>
)

View File

@@ -1,30 +1,5 @@
import { createLogger } from '@sim/logger'
import { WorkflowIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowBlock')
// Helper function to get available workflows for the dropdown
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
try {
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
// Filter out the current workflow to prevent recursion
const availableWorkflows = Object.entries(workflows)
.filter(([id]) => id !== activeWorkflowId)
.map(([id, workflow]) => ({
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
id: id,
}))
.sort((a, b) => a.label.localeCompare(b.label))
return availableWorkflows
} catch (error) {
logger.error('Error getting available workflows:', error)
return []
}
}
export const WorkflowBlock: BlockConfig = {
type: 'workflow',
@@ -38,8 +13,7 @@ export const WorkflowBlock: BlockConfig = {
{
id: 'workflowId',
title: 'Select Workflow',
type: 'combobox',
options: getAvailableWorkflows,
type: 'workflow-selector',
placeholder: 'Search workflows...',
required: true,
},

View File

@@ -1,18 +1,5 @@
import { WorkflowIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const getAvailableWorkflows = (): Array<{ label: string; id: string }> => {
try {
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
return Object.entries(workflows)
.filter(([id]) => id !== activeWorkflowId)
.map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id }))
.sort((a, b) => a.label.localeCompare(b.label))
} catch {
return []
}
}
export const WorkflowInputBlock: BlockConfig = {
type: 'workflow_input',
@@ -25,21 +12,19 @@ export const WorkflowInputBlock: BlockConfig = {
`,
category: 'blocks',
docsLink: 'https://docs.sim.ai/blocks/workflow',
bgColor: '#6366F1', // Indigo - modern and professional
bgColor: '#6366F1',
icon: WorkflowIcon,
subBlocks: [
{
id: 'workflowId',
title: 'Select Workflow',
type: 'combobox',
options: getAvailableWorkflows,
type: 'workflow-selector',
placeholder: 'Search workflows...',
required: true,
},
// Renders dynamic mapping UI based on selected child workflow's Start trigger inputFormat
{
id: 'inputMapping',
title: 'Input Mapping',
title: 'Inputs',
type: 'input-mapping',
description:
"Map fields defined in the child workflow's Start block to variables/values in this workflow.",

View File

@@ -110,15 +110,16 @@ export function getCustomTools(workspaceId?: string): CustomToolDefinition[] {
}
/**
* Get a specific custom tool from the query cache by ID (for non-React code)
* Get a specific custom tool from the query cache by ID or title (for non-React code)
* Custom tools are referenced by title in the system (custom_${title}), so title lookup is required.
* If workspaceId is not provided, extracts it from the current URL
*/
export function getCustomTool(
toolId: string,
identifier: string,
workspaceId?: string
): CustomToolDefinition | undefined {
const tools = getCustomTools(workspaceId)
return tools.find((tool) => tool.id === toolId) || tools.find((tool) => tool.title === toolId)
return tools.find((tool) => tool.id === identifier || tool.title === identifier)
}
/**

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
createOptimisticMutationHandlers,
@@ -26,34 +25,35 @@ export const workflowKeys = {
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
inputFields: (workflowId: string | undefined) =>
[...workflowKeys.all, 'inputFields', workflowId ?? ''] as const,
state: (workflowId: string | undefined) =>
[...workflowKeys.all, 'state', workflowId ?? ''] as const,
}
/**
* Fetches workflow input fields from the workflow state.
* Fetches workflow state from the API.
* Used as the base query for both state preview and input fields extraction.
*/
async function fetchWorkflowInputFields(workflowId: string): Promise<WorkflowInputField[]> {
async function fetchWorkflowState(workflowId: string): Promise<WorkflowState | null> {
const response = await fetch(`/api/workflows/${workflowId}`)
if (!response.ok) throw new Error('Failed to fetch workflow')
const { data } = await response.json()
return extractInputFieldsFromBlocks(data?.state?.blocks)
return data?.state ?? null
}
/**
* Hook to fetch workflow input fields for configuration.
* Uses React Query for caching and deduplication.
* Hook to fetch workflow state.
* Used by workflow blocks to show a preview of the child workflow
* and as a base query for input fields extraction.
*
* @param workflowId - The workflow ID to fetch input fields for
* @returns Query result with input fields array
* @param workflowId - The workflow ID to fetch state for
* @returns Query result with workflow state
*/
export function useWorkflowInputFields(workflowId: string | undefined) {
export function useWorkflowState(workflowId: string | undefined) {
return useQuery({
queryKey: workflowKeys.inputFields(workflowId),
queryFn: () => fetchWorkflowInputFields(workflowId!),
queryKey: workflowKeys.state(workflowId),
queryFn: () => fetchWorkflowState(workflowId!),
enabled: Boolean(workflowId),
staleTime: 0,
refetchOnMount: 'always',
staleTime: 30 * 1000, // 30 seconds
})
}
@@ -532,13 +532,19 @@ export interface ChildDeploymentStatus {
}
/**
* Fetches deployment status for a child workflow
* Fetches deployment status for a child workflow.
* Uses Promise.all to fetch status and deployments in parallel for better performance.
*/
async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDeploymentStatus> {
const statusRes = await fetch(`/api/workflows/${workflowId}/status`, {
cache: 'no-store',
const fetchOptions = {
cache: 'no-store' as const,
headers: { 'Cache-Control': 'no-cache' },
})
}
const [statusRes, deploymentsRes] = await Promise.all([
fetch(`/api/workflows/${workflowId}/status`, fetchOptions),
fetch(`/api/workflows/${workflowId}/deployments`, fetchOptions),
])
if (!statusRes.ok) {
throw new Error('Failed to fetch workflow status')
@@ -546,11 +552,6 @@ async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDepl
const statusData = await statusRes.json()
const deploymentsRes = await fetch(`/api/workflows/${workflowId}/deployments`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
})
let activeVersion: number | null = null
if (deploymentsRes.ok) {
const deploymentsJson = await deploymentsRes.json()
@@ -580,7 +581,7 @@ export function useChildDeploymentStatus(workflowId: string | undefined) {
queryKey: workflowKeys.deploymentStatus(workflowId),
queryFn: () => fetchChildDeploymentStatus(workflowId!),
enabled: Boolean(workflowId),
staleTime: 30 * 1000, // 30 seconds
staleTime: 0,
retry: false,
})
}

View File

@@ -6,6 +6,7 @@ import type {
SelectorOption,
SelectorQueryArgs,
} from '@/hooks/selectors/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const SELECTOR_STALE = 60 * 1000
@@ -853,6 +854,36 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
}))
},
},
'sim.workflows': {
key: 'sim.workflows',
staleTime: 0, // Always fetch fresh from store
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sim.workflows',
context.excludeWorkflowId ?? 'none',
],
enabled: () => true,
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
const { workflows } = useWorkflowRegistry.getState()
return Object.entries(workflows)
.filter(([id]) => id !== context.excludeWorkflowId)
.map(([id, workflow]) => ({
id,
label: workflow.name || `Workflow ${id.slice(0, 8)}`,
}))
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchById: async ({ detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
if (!detailId) return null
const { workflows } = useWorkflowRegistry.getState()
const workflow = workflows[detailId]
if (!workflow) return null
return {
id: detailId,
label: workflow.name || `Workflow ${detailId.slice(0, 8)}`,
}
},
},
}
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {

View File

@@ -29,6 +29,7 @@ export type SelectorKey =
| 'webflow.sites'
| 'webflow.collections'
| 'webflow.items'
| 'sim.workflows'
export interface SelectorOption {
id: string
@@ -52,6 +53,7 @@ export interface SelectorContext {
siteId?: string
collectionId?: string
spreadsheetId?: string
excludeWorkflowId?: string
}
export interface SelectorQueryArgs {

View File

@@ -64,6 +64,8 @@ import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth')
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
const validStripeKey = env.STRIPE_SECRET_KEY
let stripeClient = null
@@ -187,6 +189,10 @@ export const auth = betterAuth({
}
}
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
? getMicrosoftRefreshTokenExpiry()
: account.refreshTokenExpiresAt
await db
.update(schema.account)
.set({
@@ -195,7 +201,7 @@ export const auth = betterAuth({
refreshToken: account.refreshToken,
idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
refreshTokenExpiresAt,
scope: scopeToStore,
updatedAt: new Date(),
})
@@ -292,6 +298,13 @@ export const auth = betterAuth({
}
}
if (isMicrosoftProvider(account.providerId)) {
await db
.update(schema.account)
.set({ refreshTokenExpiresAt: getMicrosoftRefreshTokenExpiry() })
.where(eq(schema.account.id, account.id))
}
// Sync webhooks for credential sets after connecting a new credential
const requestId = crypto.randomUUID().slice(0, 8)
const userMemberships = await db
@@ -387,7 +400,6 @@ export const auth = betterAuth({
enabled: true,
allowDifferentEmails: true,
trustedProviders: [
// Standard OAuth providers
'google',
'github',
'email-password',
@@ -403,8 +415,32 @@ export const auth = betterAuth({
'hubspot',
'linkedin',
'spotify',
// Common SSO provider patterns
'google-email',
'google-calendar',
'google-drive',
'google-docs',
'google-sheets',
'google-forms',
'google-vault',
'google-groups',
'vertex-ai',
'github-repo',
'microsoft-teams',
'microsoft-excel',
'microsoft-planner',
'outlook',
'onedrive',
'sharepoint',
'jira',
'airtable',
'dropbox',
'salesforce',
'wealthbox',
'zoom',
'wordpress',
'linear',
'shopify',
'trello',
...SSO_TRUSTED_PROVIDERS,
],
},

View File

@@ -123,6 +123,8 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
interface OutputFieldSchema {
type: string
description?: string
properties?: Record<string, OutputFieldSchema>
items?: { type: string }
}
/**
@@ -256,6 +258,42 @@ function mapSubBlockTypeToSchemaType(type: string): string {
return typeMap[type] || 'string'
}
/**
* Extracts a single output field schema, including nested properties
*/
function extractOutputField(def: any): OutputFieldSchema {
if (typeof def === 'string') {
return { type: def }
}
if (typeof def !== 'object' || def === null) {
return { type: 'any' }
}
const field: OutputFieldSchema = {
type: def.type || 'any',
}
if (def.description) {
field.description = def.description
}
// Include nested properties if present
if (def.properties && typeof def.properties === 'object') {
field.properties = {}
for (const [propKey, propDef] of Object.entries(def.properties)) {
field.properties[propKey] = extractOutputField(propDef)
}
}
// Include items schema for arrays
if (def.items && typeof def.items === 'object') {
field.items = { type: def.items.type || 'any' }
}
return field
}
/**
* Extracts trigger outputs from the first available trigger
*/
@@ -272,15 +310,7 @@ function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSche
const trigger = getTrigger(triggerId)
if (trigger.outputs) {
for (const [key, def] of Object.entries(trigger.outputs)) {
if (typeof def === 'string') {
outputs[key] = { type: def }
} else if (typeof def === 'object' && def !== null) {
const typedDef = def as { type?: string; description?: string }
outputs[key] = {
type: typedDef.type || 'any',
description: typedDef.description,
}
}
outputs[key] = extractOutputField(def)
}
}
}
@@ -312,11 +342,7 @@ function extractOutputs(
const tool = toolsRegistry[toolId]
if (tool?.outputs) {
for (const [key, def] of Object.entries(tool.outputs)) {
const typedDef = def as { type: string; description?: string }
outputs[key] = {
type: typedDef.type || 'any',
description: typedDef.description,
}
outputs[key] = extractOutputField(def)
}
return outputs
}
@@ -329,15 +355,7 @@ function extractOutputs(
// Use block-level outputs
if (blockConfig.outputs) {
for (const [key, def] of Object.entries(blockConfig.outputs)) {
if (typeof def === 'string') {
outputs[key] = { type: def }
} else if (typeof def === 'object' && def !== null) {
const typedDef = def as { type?: string; description?: string }
outputs[key] = {
type: typedDef.type || 'any',
description: typedDef.description,
}
}
outputs[key] = extractOutputField(def)
}
}

View File

@@ -1,3 +1,4 @@
export * from './microsoft'
export * from './oauth'
export * from './types'
export * from './utils'

View File

@@ -0,0 +1,19 @@
export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
export const MICROSOFT_PROVIDERS = new Set([
'microsoft-excel',
'microsoft-planner',
'microsoft-teams',
'outlook',
'onedrive',
'sharepoint',
])
export function isMicrosoftProvider(providerId: string): boolean {
return MICROSOFT_PROVIDERS.has(providerId)
}
export function getMicrosoftRefreshTokenExpiry(): Date {
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
}

View File

@@ -835,6 +835,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
clientId,
clientSecret,
useBasicAuth: true,
supportsRefreshTokenRotation: true,
}
}
case 'confluence': {
@@ -883,6 +884,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
clientId,
clientSecret,
useBasicAuth: false,
supportsRefreshTokenRotation: true,
}
}
case 'microsoft':
@@ -910,6 +912,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
clientId,
clientSecret,
useBasicAuth: true,
supportsRefreshTokenRotation: true,
}
}
case 'dropbox': {

View File

@@ -643,13 +643,22 @@ async function executeToolRequest(
})
const responseHeaders = new Headers(secureResponse.headers.toRecord())
const bodyBuffer = await secureResponse.arrayBuffer()
const nullBodyStatuses = new Set([101, 204, 205, 304])
response = new Response(bodyBuffer, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
if (nullBodyStatuses.has(secureResponse.status)) {
response = new Response(null, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
} else {
const bodyBuffer = await secureResponse.arrayBuffer()
response = new Response(bodyBuffer, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
}
}
// For non-OK responses, attempt JSON first; if parsing fails, fall back to text
@@ -693,11 +702,9 @@ async function executeToolRequest(
throw errorToTransform
}
// Parse response data once with guard for empty 202 bodies
let responseData
const status = response.status
if (status === 202) {
// Many APIs (e.g., Microsoft Graph) return 202 with empty body
if (status === 202 || status === 204 || status === 205) {
responseData = { status }
} else {
if (tool.transformResponse) {