mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-23 05:47:59 -05:00
Compare commits
13 Commits
lakees/db
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528d8e7729 | ||
|
|
04a6f9d0a4 | ||
|
|
76dd4a0c95 | ||
|
|
66dfe2c6b2 | ||
|
|
376f7cb571 | ||
|
|
42159c23b9 | ||
|
|
2f0f246002 | ||
|
|
900d3ef9ea | ||
|
|
f3fcc28f89 | ||
|
|
7cfdf46724 | ||
|
|
d681451297 | ||
|
|
5987a6d060 | ||
|
|
e2ccefb2f4 |
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
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 Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -22,6 +21,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
|||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
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 { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
@@ -107,7 +107,6 @@ export default function LoginPage({
|
|||||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||||
const [showValidationError, setShowValidationError] = useState(false)
|
const [showValidationError, setShowValidationError] = useState(false)
|
||||||
const buttonClass = useBrandedButtonClass()
|
const buttonClass = useBrandedButtonClass()
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||||
@@ -115,7 +114,6 @@ export default function LoginPage({
|
|||||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||||
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
|
|
||||||
const [resetStatus, setResetStatus] = useState<{
|
const [resetStatus, setResetStatus] = useState<{
|
||||||
type: 'success' | 'error' | null
|
type: 'success' | 'error' | null
|
||||||
message: string
|
message: string
|
||||||
@@ -184,6 +182,13 @@ export default function LoginPage({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
|
const redirectToVerify = (emailToVerify: string) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.setItem('verificationEmail', emailToVerify)
|
||||||
|
}
|
||||||
|
router.push('/verify')
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget)
|
const formData = new FormData(e.currentTarget)
|
||||||
const emailRaw = formData.get('email') as string
|
const emailRaw = formData.get('email') as string
|
||||||
const email = emailRaw.trim().toLowerCase()
|
const email = emailRaw.trim().toLowerCase()
|
||||||
@@ -215,9 +220,9 @@ export default function LoginPage({
|
|||||||
onError: (ctx) => {
|
onError: (ctx) => {
|
||||||
logger.error('Login error:', ctx.error)
|
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')) {
|
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||||
errorHandled = true
|
errorHandled = true
|
||||||
|
redirectToVerify(email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,10 +290,7 @@ export default function LoginPage({
|
|||||||
router.push(safeCallbackUrl)
|
router.push(safeCallbackUrl)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||||
if (typeof window !== 'undefined') {
|
redirectToVerify(email)
|
||||||
sessionStorage.setItem('verificationEmail', email)
|
|
||||||
}
|
|
||||||
router.push('/verify')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,24 +493,14 @@ export default function LoginPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
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}
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
loadingText='Signing in'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Sign in
|
||||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
</BrandedButton>
|
||||||
<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>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -619,25 +611,15 @@ export default function LoginPage({
|
|||||||
<p>{resetStatus.message}</p>
|
<p>{resetStatus.message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<BrandedButton
|
||||||
type='button'
|
type='button'
|
||||||
onClick={handleForgotPassword}
|
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}
|
disabled={isSubmittingReset}
|
||||||
|
loading={isSubmittingReset}
|
||||||
|
loadingText='Sending'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Send Reset Link
|
||||||
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
|
</BrandedButton>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
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 {
|
interface RequestResetFormProps {
|
||||||
email: string
|
email: string
|
||||||
@@ -28,9 +27,6 @@ export function RequestResetForm({
|
|||||||
statusMessage,
|
statusMessage,
|
||||||
className,
|
className,
|
||||||
}: RequestResetFormProps) {
|
}: RequestResetFormProps) {
|
||||||
const buttonClass = useBrandedButtonClass()
|
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit(email)
|
onSubmit(email)
|
||||||
@@ -68,24 +64,14 @@ export function RequestResetForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onMouseEnter={() => setIsButtonHovered(true)}
|
loading={isSubmitting}
|
||||||
onMouseLeave={() => setIsButtonHovered(false)}
|
loadingText='Sending'
|
||||||
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'
|
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Send Reset Link
|
||||||
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
</BrandedButton>
|
||||||
<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>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -112,8 +98,6 @@ export function SetNewPasswordForm({
|
|||||||
const [validationMessage, setValidationMessage] = useState('')
|
const [validationMessage, setValidationMessage] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||||
const buttonClass = useBrandedButtonClass()
|
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -243,24 +227,14 @@ export function SetNewPasswordForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
disabled={isSubmitting || !token}
|
|
||||||
type='submit'
|
type='submit'
|
||||||
onMouseEnter={() => setIsButtonHovered(true)}
|
disabled={isSubmitting || !token}
|
||||||
onMouseLeave={() => setIsButtonHovered(false)}
|
loading={isSubmitting}
|
||||||
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'
|
loadingText='Resetting'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Reset Password
|
||||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
</BrandedButton>
|
||||||
<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>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
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 Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { client, useSession } from '@/lib/auth/auth-client'
|
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 { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
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 { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
@@ -97,7 +97,6 @@ function SignupFormContent({
|
|||||||
const [redirectUrl, setRedirectUrl] = useState('')
|
const [redirectUrl, setRedirectUrl] = useState('')
|
||||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||||
const buttonClass = useBrandedButtonClass()
|
const buttonClass = useBrandedButtonClass()
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||||
@@ -476,24 +475,14 @@ function SignupFormContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
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}
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
loadingText='Creating account'
|
||||||
>
|
>
|
||||||
<span className='flex items-center gap-1'>
|
Create account
|
||||||
{isLoading ? 'Creating account' : 'Create account'}
|
</BrandedButton>
|
||||||
<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>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useRef, useState } from 'react'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { Textarea } from '@/components/emcn'
|
import { Textarea } from '@/components/emcn'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +17,7 @@ import { isHosted } from '@/lib/core/config/feature-flags'
|
|||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
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 Footer from '@/app/(landing)/components/footer/footer'
|
||||||
import Nav from '@/app/(landing)/components/nav/nav'
|
import Nav from '@/app/(landing)/components/nav/nav'
|
||||||
|
|
||||||
@@ -493,18 +493,17 @@ export default function CareersPage() {
|
|||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className='flex justify-end pt-2'>
|
<div className='flex justify-end pt-2'>
|
||||||
<Button
|
<BrandedButton
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isSubmitting || submitStatus === 'success'}
|
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'
|
loading={isSubmitting}
|
||||||
size='lg'
|
loadingText='Submitting'
|
||||||
|
showArrow={false}
|
||||||
|
fullWidth={false}
|
||||||
|
className='min-w-[200px]'
|
||||||
>
|
>
|
||||||
{isSubmitting
|
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
|
||||||
? 'Submitting...'
|
</BrandedButton>
|
||||||
: submitStatus === 'success'
|
|
||||||
? 'Submitted'
|
|
||||||
: 'Submit Application'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useBrandConfig } from '@/lib/branding/branding'
|
|||||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||||
|
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||||
|
|
||||||
const logger = createLogger('nav')
|
const logger = createLogger('nav')
|
||||||
|
|
||||||
@@ -20,11 +21,12 @@ interface NavProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: 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 [isHovered, setIsHovered] = useState(false)
|
||||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const brand = useBrandConfig()
|
const brand = useBrandConfig()
|
||||||
|
const buttonClass = useBrandedButtonClass()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variant !== 'landing') return
|
if (variant !== 'landing') return
|
||||||
@@ -183,7 +185,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
|||||||
href='/signup'
|
href='/signup'
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
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'
|
aria-label='Get started with Sim - Sign up for free'
|
||||||
prefetch={true}
|
prefetch={true}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { refreshOAuthToken } from '@/lib/oauth'
|
import { refreshOAuthToken } from '@/lib/oauth'
|
||||||
|
import {
|
||||||
|
getMicrosoftRefreshTokenExpiry,
|
||||||
|
isMicrosoftProvider,
|
||||||
|
PROACTIVE_REFRESH_THRESHOLD_DAYS,
|
||||||
|
} from '@/lib/oauth/microsoft'
|
||||||
|
|
||||||
const logger = createLogger('OAuthUtilsAPI')
|
const logger = createLogger('OAuthUtilsAPI')
|
||||||
|
|
||||||
@@ -205,15 +210,32 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decide if we should refresh: token missing OR expired
|
// 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 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
|
const accessToken = credential.accessToken
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
logger.info(`[${requestId}] Refreshing token for credential`)
|
||||||
try {
|
try {
|
||||||
const refreshedToken = await refreshOAuthToken(
|
const refreshedToken = await refreshOAuthToken(
|
||||||
credential.providerId,
|
credential.providerId,
|
||||||
@@ -227,11 +249,15 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
userId: credential.userId,
|
userId: credential.userId,
|
||||||
hasRefreshToken: !!credential.refreshToken,
|
hasRefreshToken: !!credential.refreshToken,
|
||||||
})
|
})
|
||||||
|
if (!accessTokenNeedsRefresh && accessToken) {
|
||||||
|
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: any = {
|
const updateData: Record<string, unknown> = {
|
||||||
accessToken: refreshedToken.accessToken,
|
accessToken: refreshedToken.accessToken,
|
||||||
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -243,6 +269,10 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
updateData.refreshToken = refreshedToken.refreshToken
|
updateData.refreshToken = refreshedToken.refreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMicrosoftProvider(credential.providerId)) {
|
||||||
|
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||||
|
}
|
||||||
|
|
||||||
// Update the token in the database
|
// Update the token in the database
|
||||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
|
|
||||||
@@ -256,6 +286,10 @@ export async function refreshAccessTokenIfNeeded(
|
|||||||
credentialId,
|
credentialId,
|
||||||
userId: credential.userId,
|
userId: credential.userId,
|
||||||
})
|
})
|
||||||
|
if (!accessTokenNeedsRefresh && accessToken) {
|
||||||
|
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
} else if (!accessToken) {
|
} else if (!accessToken) {
|
||||||
@@ -277,10 +311,27 @@ export async function refreshTokenIfNeeded(
|
|||||||
credentialId: string
|
credentialId: string
|
||||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||||
// Decide if we should refresh: token missing OR expired
|
// 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 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 token appears valid and present, return it directly
|
||||||
if (!shouldRefresh) {
|
if (!shouldRefresh) {
|
||||||
@@ -293,13 +344,17 @@ export async function refreshTokenIfNeeded(
|
|||||||
|
|
||||||
if (!refreshResult) {
|
if (!refreshResult) {
|
||||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
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')
|
throw new Error('Failed to refresh token')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: any = {
|
const updateData: Record<string, unknown> = {
|
||||||
accessToken: refreshedToken,
|
accessToken: refreshedToken,
|
||||||
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -311,6 +366,10 @@ export async function refreshTokenIfNeeded(
|
|||||||
updateData.refreshToken = newRefreshToken
|
updateData.refreshToken = newRefreshToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMicrosoftProvider(credential.providerId)) {
|
||||||
|
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||||
|
}
|
||||||
|
|
||||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully refreshed access token`)
|
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)
|
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/sim/app/changelog/components/branded-link.tsx
Normal file
27
apps/sim/app/changelog/components/branded-link.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BookOpen, Github, Rss } from 'lucide-react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
|
import { BrandedLink } from '@/app/changelog/components/branded-link'
|
||||||
import ChangelogList from '@/app/changelog/components/timeline-list'
|
import ChangelogList from '@/app/changelog/components/timeline-list'
|
||||||
|
|
||||||
export interface ChangelogEntry {
|
export interface ChangelogEntry {
|
||||||
@@ -66,25 +67,24 @@ export default async function ChangelogContent() {
|
|||||||
<hr className='mt-6 border-border' />
|
<hr className='mt-6 border-border' />
|
||||||
|
|
||||||
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
|
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
|
||||||
<Link
|
<BrandedLink
|
||||||
href='https://github.com/simstudioai/sim/releases'
|
href='https://github.com/simstudioai/sim/releases'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
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' />
|
<Github className='h-4 w-4' />
|
||||||
View on GitHub
|
View on GitHub
|
||||||
</Link>
|
</BrandedLink>
|
||||||
<Link
|
<Link
|
||||||
href='https://docs.sim.ai'
|
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' />
|
<BookOpen className='h-4 w-4' />
|
||||||
Documentation
|
Documentation
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href='/changelog.xml'
|
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 className='h-4 w-4' />
|
||||||
RSS Feed
|
RSS Feed
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesContainerRef = 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 [conversationId, setConversationId] = useState('')
|
||||||
|
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||||
|
|||||||
@@ -207,7 +207,6 @@ function TemplateCardInner({
|
|||||||
isPannable={false}
|
isPannable={false}
|
||||||
defaultZoom={0.8}
|
defaultZoom={0.8}
|
||||||
fitPadding={0.2}
|
fitPadding={0.2}
|
||||||
lightweight
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='h-full w-full bg-[var(--surface-4)]' />
|
<div className='h-full w-full bg-[var(--surface-4)]' />
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
BlockDetailsSidebar,
|
|
||||||
getLeftmostBlockId,
|
getLeftmostBlockId,
|
||||||
|
PreviewEditor,
|
||||||
WorkflowPreview,
|
WorkflowPreview,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||||
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
||||||
@@ -248,11 +248,10 @@ export function ExecutionSnapshot({
|
|||||||
cursorStyle='pointer'
|
cursorStyle='pointer'
|
||||||
executedBlocks={blockExecutions}
|
executedBlocks={blockExecutions}
|
||||||
selectedBlockId={pinnedBlockId}
|
selectedBlockId={pinnedBlockId}
|
||||||
lightweight
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
||||||
<BlockDetailsSidebar
|
<PreviewEditor
|
||||||
block={workflowState.blocks[pinnedBlockId]}
|
block={workflowState.blocks[pinnedBlockId]}
|
||||||
executionData={blockExecutions[pinnedBlockId]}
|
executionData={blockExecutions[pinnedBlockId]}
|
||||||
allBlockExecutions={blockExecutions}
|
allBlockExecutions={blockExecutions}
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ function TemplateCardInner({
|
|||||||
isPannable={false}
|
isPannable={false}
|
||||||
defaultZoom={0.8}
|
defaultZoom={0.8}
|
||||||
fitPadding={0.2}
|
fitPadding={0.2}
|
||||||
lightweight
|
|
||||||
cursorStyle='pointer'
|
cursorStyle='pointer'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
mode,
|
mode,
|
||||||
setMode,
|
setMode,
|
||||||
isAborting,
|
isAborting,
|
||||||
|
maskCredentialValue,
|
||||||
} = useCopilotStore()
|
} = useCopilotStore()
|
||||||
|
|
||||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||||
@@ -210,7 +211,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
const isLastTextBlock =
|
const isLastTextBlock =
|
||||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||||
const parsed = parseSpecialTags(block.content)
|
const parsed = parseSpecialTags(block.content)
|
||||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
// Mask credential IDs in the displayed content
|
||||||
|
const cleanBlockContent = maskCredentialValue(
|
||||||
|
parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||||
|
)
|
||||||
|
|
||||||
if (!cleanBlockContent.trim()) return null
|
if (!cleanBlockContent.trim()) return null
|
||||||
|
|
||||||
@@ -238,7 +242,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return (
|
return (
|
||||||
<div key={blockKey} className='w-full'>
|
<div key={blockKey} className='w-full'>
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={block.content}
|
content={maskCredentialValue(block.content)}
|
||||||
isStreaming={isActivelyStreaming}
|
isStreaming={isActivelyStreaming}
|
||||||
hasFollowingContent={hasFollowingContent}
|
hasFollowingContent={hasFollowingContent}
|
||||||
hasSpecialTags={hasSpecialTags}
|
hasSpecialTags={hasSpecialTags}
|
||||||
@@ -261,7 +265,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
|
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue])
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -782,6 +782,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
const startTimeRef = useRef<number>(Date.now())
|
const startTimeRef = useRef<number>(Date.now())
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
const wasStreamingRef = useRef(false)
|
const wasStreamingRef = useRef(false)
|
||||||
|
|
||||||
// Only show streaming animations for current message
|
// Only show streaming animations for current message
|
||||||
@@ -816,14 +817,16 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
currentText += parsed.cleanContent
|
currentText += parsed.cleanContent
|
||||||
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
currentText = ''
|
currentText = ''
|
||||||
}
|
}
|
||||||
segments.push({ type: 'tool', block })
|
segments.push({ type: 'tool', block })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParsed = parseSpecialTags(allRawText)
|
const allParsed = parseSpecialTags(allRawText)
|
||||||
@@ -952,6 +955,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
toolCall: CopilotToolCall
|
toolCall: CopilotToolCall
|
||||||
}) {
|
}) {
|
||||||
const blocks = useWorkflowStore((s) => s.blocks)
|
const blocks = useWorkflowStore((s) => s.blocks)
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
|
|
||||||
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
||||||
|
|
||||||
@@ -983,6 +987,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: string
|
title: string
|
||||||
value: any
|
value: any
|
||||||
isPassword?: boolean
|
isPassword?: boolean
|
||||||
|
isCredential?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockChange {
|
interface BlockChange {
|
||||||
@@ -1091,6 +1096,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: subBlockConfig.title ?? subBlockConfig.id,
|
title: subBlockConfig.title ?? subBlockConfig.id,
|
||||||
value,
|
value,
|
||||||
isPassword: subBlockConfig.password === true,
|
isPassword: subBlockConfig.password === true,
|
||||||
|
isCredential: subBlockConfig.type === 'oauth-input',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,8 +1178,15 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
||||||
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
||||||
{subBlocksToShow.map((sb) => {
|
{subBlocksToShow.map((sb) => {
|
||||||
// Mask password fields like the canvas does
|
// Mask password fields and credential IDs
|
||||||
const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value)
|
let displayValue: string
|
||||||
|
if (sb.isPassword) {
|
||||||
|
displayValue = '•••'
|
||||||
|
} else {
|
||||||
|
// Get display value first, then mask any credential IDs that might be in it
|
||||||
|
const rawValue = getDisplayValue(sb.value)
|
||||||
|
displayValue = maskCredentialValue(rawValue)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
||||||
<span
|
<span
|
||||||
@@ -1412,10 +1425,13 @@ function RunSkipButtons({
|
|||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
setButtonsHidden(true)
|
setButtonsHidden(true)
|
||||||
try {
|
try {
|
||||||
// Add to auto-allowed list first
|
// Add to auto-allowed list - this also executes all pending integration tools of this type
|
||||||
await addAutoAllowedTool(toolCall.name)
|
await addAutoAllowedTool(toolCall.name)
|
||||||
// Then execute
|
// For client tools with interrupts (not integration tools), we still need to call handleRun
|
||||||
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
// since executeIntegrationTool only works for server-side tools
|
||||||
|
if (!isIntegrationTool(toolCall.name)) {
|
||||||
|
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
actionInProgressRef.current = false
|
actionInProgressRef.current = false
|
||||||
@@ -1438,10 +1454,10 @@ function RunSkipButtons({
|
|||||||
|
|
||||||
if (buttonsHidden) return null
|
if (buttonsHidden) return null
|
||||||
|
|
||||||
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
|
// Show "Always Allow" for all tools that require confirmation
|
||||||
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
|
const showAlwaysAllow = true
|
||||||
|
|
||||||
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
|
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||||
return (
|
return (
|
||||||
<div className='mt-[10px] flex gap-[6px]'>
|
<div className='mt-[10px] flex gap-[6px]'>
|
||||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Load auto-allowed tools once on mount */
|
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
|
||||||
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
|
if (!hasLoadedAutoAllowedToolsRef.current) {
|
||||||
hasLoadedAutoAllowedToolsRef.current = true
|
hasLoadedAutoAllowedToolsRef.current = true
|
||||||
loadAutoAllowedTools().catch((err) => {
|
loadAutoAllowedTools().catch((err) => {
|
||||||
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui'
|
import { Skeleton } from '@/components/ui'
|
||||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||||
import {
|
import {
|
||||||
BlockDetailsSidebar,
|
|
||||||
getLeftmostBlockId,
|
getLeftmostBlockId,
|
||||||
|
PreviewEditor,
|
||||||
WorkflowPreview,
|
WorkflowPreview,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||||
@@ -337,7 +337,7 @@ export function GeneralDeploy({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||||
<BlockDetailsSidebar
|
<PreviewEditor
|
||||||
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
||||||
workflowVariables={workflowToShow.variables}
|
workflowVariables={workflowToShow.variables}
|
||||||
loops={workflowToShow.loops}
|
loops={workflowToShow.loops}
|
||||||
|
|||||||
@@ -446,7 +446,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
isPannable={false}
|
isPannable={false}
|
||||||
defaultZoom={0.8}
|
defaultZoom={0.8}
|
||||||
fitPadding={0.2}
|
fitPadding={0.2}
|
||||||
lightweight
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ export { Text } from './text/text'
|
|||||||
export { TimeInput } from './time-input/time-input'
|
export { TimeInput } from './time-input/time-input'
|
||||||
export { ToolInput } from './tool-input/tool-input'
|
export { ToolInput } from './tool-input/tool-input'
|
||||||
export { VariablesInput } from './variables-input/variables-input'
|
export { VariablesInput } from './variables-input/variables-input'
|
||||||
|
export { WorkflowSelectorInput } from './workflow-selector/workflow-selector-input'
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { useMemo, useRef, useState } from 'react'
|
|||||||
import { Badge, Input } from '@/components/emcn'
|
import { Badge, Input } from '@/components/emcn'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
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 { 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 { 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 { 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 { 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 { 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
|
* Props for the InputMappingField component
|
||||||
@@ -70,7 +71,11 @@ export function InputMapping({
|
|||||||
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
|
|
||||||
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
|
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 [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const valueObj: Record<string, string> = useMemo(() => {
|
const valueObj: Record<string, string> = useMemo(() => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type OAuthProvider,
|
type OAuthProvider,
|
||||||
type OAuthService,
|
type OAuthService,
|
||||||
} from '@/lib/oauth'
|
} from '@/lib/oauth'
|
||||||
|
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import {
|
import {
|
||||||
CheckboxList,
|
CheckboxList,
|
||||||
@@ -65,7 +66,7 @@ import { useForceRefreshMcpTools, useMcpServers, useStoredMcpTools } from '@/hoo
|
|||||||
import {
|
import {
|
||||||
useChildDeploymentStatus,
|
useChildDeploymentStatus,
|
||||||
useDeployChildWorkflow,
|
useDeployChildWorkflow,
|
||||||
useWorkflowInputFields,
|
useWorkflowState,
|
||||||
useWorkflows,
|
useWorkflows,
|
||||||
} from '@/hooks/queries/workflows'
|
} from '@/hooks/queries/workflows'
|
||||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||||
@@ -771,7 +772,11 @@ function WorkflowInputMapperSyncWrapper({
|
|||||||
disabled: boolean
|
disabled: boolean
|
||||||
workflowId: string
|
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(() => {
|
const parsedValue = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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...'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
TimeInput,
|
TimeInput,
|
||||||
ToolInput,
|
ToolInput,
|
||||||
VariablesInput,
|
VariablesInput,
|
||||||
|
WorkflowSelectorInput,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
} 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 { 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'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
@@ -90,7 +91,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
|
|||||||
if (!config.required) return false
|
if (!config.required) return false
|
||||||
if (typeof config.required === 'boolean') return config.required
|
if (typeof config.required === 'boolean') return config.required
|
||||||
|
|
||||||
// Helper function to evaluate a condition
|
|
||||||
const evalCond = (
|
const evalCond = (
|
||||||
cond: {
|
cond: {
|
||||||
field: string
|
field: string
|
||||||
@@ -132,7 +132,6 @@ const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string,
|
|||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
// If required is a condition object or function, evaluate it
|
|
||||||
const condition = typeof config.required === 'function' ? config.required() : config.required
|
const condition = typeof config.required === 'function' ? config.required() : config.required
|
||||||
return evalCond(condition, subBlockValues || {})
|
return evalCond(condition, subBlockValues || {})
|
||||||
}
|
}
|
||||||
@@ -378,7 +377,6 @@ function SubBlockComponent({
|
|||||||
setIsValidJson(isValid)
|
setIsValidJson(isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if wand is enabled for this sub-block
|
|
||||||
const isWandEnabled = config.wandConfig?.enabled ?? false
|
const isWandEnabled = config.wandConfig?.enabled ?? false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,8 +436,6 @@ function SubBlockComponent({
|
|||||||
| null
|
| null
|
||||||
| undefined
|
| 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, {
|
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
|
||||||
disabled,
|
disabled,
|
||||||
isPreview,
|
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':
|
case 'mcp-server-selector':
|
||||||
return (
|
return (
|
||||||
<McpServerSelector
|
<McpServerSelector
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function SubflowEditor({
|
|||||||
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
|
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
|
||||||
{/* Subflow Editor Section */}
|
{/* Subflow Editor Section */}
|
||||||
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
|
<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 */}
|
{/* Type Selection */}
|
||||||
<div>
|
<div>
|
||||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { isEqual } from 'lodash'
|
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 { useShallow } from 'zustand/react/shallow'
|
||||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
import { Button, Tooltip } from '@/components/emcn'
|
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 { 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 { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
|
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||||
import { getBlock } from '@/blocks/registry'
|
import { getBlock } from '@/blocks/registry'
|
||||||
import type { SubBlockType } from '@/blocks/types'
|
import type { SubBlockType } from '@/blocks/types'
|
||||||
|
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { usePanelEditorStore } from '@/stores/panel'
|
import { usePanelEditorStore } from '@/stores/panel'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
@@ -85,6 +96,14 @@ export function Editor() {
|
|||||||
// Get subflow display properties from configs
|
// Get subflow display properties from configs
|
||||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
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
|
// Refs for resize functionality
|
||||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -254,11 +273,11 @@ export function Editor() {
|
|||||||
|
|
||||||
// Trigger rename mode when signaled from context menu
|
// Trigger rename mode when signaled from context menu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldFocusRename && currentBlock && !isSubflow) {
|
if (shouldFocusRename && currentBlock) {
|
||||||
handleStartRename()
|
handleStartRename()
|
||||||
setShouldFocusRename(false)
|
setShouldFocusRename(false)
|
||||||
}
|
}
|
||||||
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
|
}, [shouldFocusRename, currentBlock, handleStartRename, setShouldFocusRename])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles opening documentation link in a new secure tab.
|
* 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)
|
// Determine if connections are at minimum height (collapsed state)
|
||||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||||
|
|
||||||
@@ -322,7 +357,7 @@ export function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||||
{/* Rename button */}
|
{/* Rename button */}
|
||||||
{currentBlock && !isSubflow && (
|
{currentBlock && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -408,7 +443,66 @@ export function Editor() {
|
|||||||
className='subblocks-section flex flex-1 flex-col overflow-hidden'
|
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]'>
|
<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]'>
|
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
|
||||||
This block has no subblocks
|
This block has no subblocks
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,11 +21,9 @@ interface WorkflowPreviewBlockData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight block component for workflow previews.
|
* Preview block component for workflow visualization.
|
||||||
* Renders block header, dummy subblocks skeleton, and handles.
|
* Renders block header, subblocks skeleton, and handles without
|
||||||
* Respects horizontalHandles and enabled state from workflow.
|
* hooks, store subscriptions, or interactive features.
|
||||||
* No heavy hooks, store subscriptions, or interactive features.
|
|
||||||
* Used in template cards and other preview contexts for performance.
|
|
||||||
*/
|
*/
|
||||||
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ interface WorkflowVariable {
|
|||||||
value: unknown
|
value: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockDetailsSidebarProps {
|
interface PreviewEditorProps {
|
||||||
block: BlockState
|
block: BlockState
|
||||||
executionData?: ExecutionData
|
executionData?: ExecutionData
|
||||||
/** All block execution data for resolving variable references */
|
/** 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.
|
* Readonly sidebar panel showing block configuration using SubBlock components.
|
||||||
*/
|
*/
|
||||||
function BlockDetailsSidebarContent({
|
function PreviewEditorContent({
|
||||||
block,
|
block,
|
||||||
executionData,
|
executionData,
|
||||||
allBlockExecutions,
|
allBlockExecutions,
|
||||||
@@ -732,7 +732,7 @@ function BlockDetailsSidebarContent({
|
|||||||
parallels,
|
parallels,
|
||||||
isExecutionMode = false,
|
isExecutionMode = false,
|
||||||
onClose,
|
onClose,
|
||||||
}: BlockDetailsSidebarProps) {
|
}: PreviewEditorProps) {
|
||||||
// Convert Record<string, Variable> to Array<Variable> for iteration
|
// Convert Record<string, Variable> to Array<Variable> for iteration
|
||||||
const normalizedWorkflowVariables = useMemo(() => {
|
const normalizedWorkflowVariables = useMemo(() => {
|
||||||
if (!workflowVariables) return []
|
if (!workflowVariables) return []
|
||||||
@@ -998,6 +998,22 @@ function BlockDetailsSidebarContent({
|
|||||||
})
|
})
|
||||||
}, [extractedRefs.envVars])
|
}, [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)
|
// Check if this is a subflow block (loop or parallel)
|
||||||
const isSubflow = block.type === 'loop' || block.type === 'parallel'
|
const isSubflow = block.type === 'loop' || block.type === 'parallel'
|
||||||
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
|
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 canonicalModeOverrides = block.data?.canonicalModes
|
||||||
const effectiveAdvanced =
|
const effectiveAdvanced =
|
||||||
(block.advancedMode ?? false) ||
|
(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 (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<BlockDetailsSidebarContent {...props} />
|
<PreviewEditorContent {...props} />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,9 @@ interface WorkflowPreviewSubflowData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight subflow component for workflow previews.
|
* Preview subflow component for workflow visualization.
|
||||||
* Matches the styling of the actual SubflowNodeComponent but without
|
* Renders loop/parallel containers without hooks, store subscriptions,
|
||||||
* hooks, store subscriptions, or interactive features.
|
* or interactive features.
|
||||||
* Used in template cards and other preview contexts for performance.
|
|
||||||
*/
|
*/
|
||||||
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
||||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { BlockDetailsSidebar } from './components/block-details-sidebar'
|
export { PreviewEditor } from './components/preview-editor'
|
||||||
export { getLeftmostBlockId, WorkflowPreview } from './preview'
|
export { getLeftmostBlockId, WorkflowPreview } from './preview'
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ import 'reactflow/dist/style.css'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
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 { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||||
import { getBlock } from '@/blocks'
|
|
||||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
const logger = createLogger('WorkflowPreview')
|
const logger = createLogger('WorkflowPreview')
|
||||||
@@ -134,8 +130,6 @@ interface WorkflowPreviewProps {
|
|||||||
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||||
/** Callback when the canvas (empty area) is clicked */
|
/** Callback when the canvas (empty area) is clicked */
|
||||||
onPaneClick?: () => void
|
onPaneClick?: () => void
|
||||||
/** Use lightweight blocks for better performance in template cards */
|
|
||||||
lightweight?: boolean
|
|
||||||
/** Cursor style to show when hovering the canvas */
|
/** Cursor style to show when hovering the canvas */
|
||||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
/** 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 = {
|
const previewNodeTypes: 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 = {
|
|
||||||
workflowBlock: WorkflowPreviewBlock,
|
workflowBlock: WorkflowPreviewBlock,
|
||||||
noteBlock: WorkflowPreviewBlock,
|
noteBlock: WorkflowPreviewBlock,
|
||||||
subflowNode: WorkflowPreviewSubflow,
|
subflowNode: WorkflowPreviewSubflow,
|
||||||
@@ -172,17 +157,19 @@ const edgeTypes: EdgeTypes = {
|
|||||||
interface FitViewOnChangeProps {
|
interface FitViewOnChangeProps {
|
||||||
nodeIds: string
|
nodeIds: string
|
||||||
fitPadding: number
|
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.
|
* Only triggers on actual node additions/removals, not on selection changes.
|
||||||
* Must be rendered inside ReactFlowProvider.
|
* Must be rendered inside ReactFlowProvider.
|
||||||
*/
|
*/
|
||||||
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const lastNodeIdsRef = useRef<string | null>(null)
|
const lastNodeIdsRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Fit view when nodes change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!nodeIds.length) return
|
if (!nodeIds.length) return
|
||||||
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
||||||
@@ -195,6 +182,27 @@ function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
|||||||
return () => clearTimeout(timeoutId)
|
return () => clearTimeout(timeoutId)
|
||||||
}, [nodeIds, fitPadding, fitView])
|
}, [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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,15 +218,12 @@ export function WorkflowPreview({
|
|||||||
onNodeClick,
|
onNodeClick,
|
||||||
onNodeContextMenu,
|
onNodeContextMenu,
|
||||||
onPaneClick,
|
onPaneClick,
|
||||||
lightweight = false,
|
|
||||||
cursorStyle = 'grab',
|
cursorStyle = 'grab',
|
||||||
executedBlocks,
|
executedBlocks,
|
||||||
selectedBlockId,
|
selectedBlockId,
|
||||||
}: WorkflowPreviewProps) {
|
}: WorkflowPreviewProps) {
|
||||||
const nodeTypes = useMemo(
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
() => (lightweight ? lightweightNodeTypes : fullNodeTypes),
|
const nodeTypes = previewNodeTypes
|
||||||
[lightweight]
|
|
||||||
)
|
|
||||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||||
|
|
||||||
const blocksStructure = useMemo(() => {
|
const blocksStructure = useMemo(() => {
|
||||||
@@ -288,119 +293,28 @@ export function WorkflowPreview({
|
|||||||
|
|
||||||
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
|
||||||
|
|
||||||
if (lightweight) {
|
// Handle loop/parallel containers
|
||||||
if (block.type === 'loop' || block.type === 'parallel') {
|
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') {
|
|
||||||
const isSelected = selectedBlockId === blockId
|
const isSelected = selectedBlockId === blockId
|
||||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||||
nodeArray.push({
|
nodeArray.push({
|
||||||
id: blockId,
|
id: blockId,
|
||||||
type: 'subflowNode',
|
type: 'subflowNode',
|
||||||
position: absolutePosition,
|
position: absolutePosition,
|
||||||
parentId: block.data?.parentId,
|
|
||||||
extent: block.data?.extent || undefined,
|
|
||||||
draggable: false,
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
...block.data,
|
|
||||||
name: block.name,
|
name: block.name,
|
||||||
width: dimensions.width,
|
width: dimensions.width,
|
||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
state: 'valid',
|
kind: block.type as 'loop' | 'parallel',
|
||||||
isPreview: true,
|
|
||||||
isPreviewSelected: isSelected,
|
isPreviewSelected: isSelected,
|
||||||
kind: 'loop',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === 'parallel') {
|
// Handle regular blocks
|
||||||
const isSelected = selectedBlockId === blockId
|
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'
|
|
||||||
|
|
||||||
let executionStatus: ExecutionStatus | undefined
|
let executionStatus: ExecutionStatus | undefined
|
||||||
if (executedBlocks) {
|
if (executedBlocks) {
|
||||||
@@ -418,24 +332,20 @@ export function WorkflowPreview({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelected = selectedBlockId === blockId
|
|
||||||
|
|
||||||
nodeArray.push({
|
nodeArray.push({
|
||||||
id: blockId,
|
id: blockId,
|
||||||
type: nodeType,
|
type: 'workflowBlock',
|
||||||
position: absolutePosition,
|
position: absolutePosition,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
// Blocks inside subflows need higher z-index to appear above the container
|
// Blocks inside subflows need higher z-index to appear above the container
|
||||||
zIndex: block.data?.parentId ? 10 : undefined,
|
zIndex: block.data?.parentId ? 10 : undefined,
|
||||||
data: {
|
data: {
|
||||||
type: block.type,
|
type: block.type,
|
||||||
config: blockConfig,
|
|
||||||
name: block.name,
|
name: block.name,
|
||||||
blockState: block,
|
isTrigger: block.triggerMode === true,
|
||||||
canEdit: false,
|
horizontalHandles: block.horizontalHandles ?? false,
|
||||||
isPreview: true,
|
enabled: block.enabled ?? true,
|
||||||
isPreviewSelected: isSelected,
|
isPreviewSelected: isSelected,
|
||||||
subBlockValues: block.subBlocks ?? {},
|
|
||||||
executionStatus,
|
executionStatus,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -448,7 +358,6 @@ export function WorkflowPreview({
|
|||||||
parallelsStructure,
|
parallelsStructure,
|
||||||
workflowState.blocks,
|
workflowState.blocks,
|
||||||
isValidWorkflowState,
|
isValidWorkflowState,
|
||||||
lightweight,
|
|
||||||
executedBlocks,
|
executedBlocks,
|
||||||
selectedBlockId,
|
selectedBlockId,
|
||||||
])
|
])
|
||||||
@@ -506,6 +415,7 @@ export function WorkflowPreview({
|
|||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
||||||
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
|
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__selectionpane { cursor: ${cursorStyle} !important; }
|
||||||
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
|
.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 */
|
/* 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 { cursor: pointer !important; }
|
||||||
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
||||||
@@ -563,7 +487,11 @@ export function WorkflowPreview({
|
|||||||
}
|
}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
/>
|
/>
|
||||||
<FitViewOnChange nodeIds={blocksStructure.ids} fitPadding={fitPadding} />
|
<FitViewOnChange
|
||||||
|
nodeIds={blocksStructure.ids}
|
||||||
|
fitPadding={fitPadding}
|
||||||
|
containerRef={containerRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
|||||||
* Avatar display configuration for responsive layout.
|
* Avatar display configuration for responsive layout.
|
||||||
*/
|
*/
|
||||||
const AVATAR_CONFIG = {
|
const AVATAR_CONFIG = {
|
||||||
MIN_COUNT: 3,
|
MIN_COUNT: 4,
|
||||||
MAX_COUNT: 12,
|
MAX_COUNT: 12,
|
||||||
WIDTH_PER_AVATAR: 20,
|
WIDTH_PER_AVATAR: 20,
|
||||||
} as const
|
} as const
|
||||||
@@ -106,7 +106,9 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
|
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate visible users and overflow count
|
* Calculate visible users and overflow count.
|
||||||
|
* Shows up to maxVisible avatars, with overflow indicator for any remaining.
|
||||||
|
* Users are reversed so new avatars appear on the left (keeping right side stable).
|
||||||
*/
|
*/
|
||||||
const { visibleUsers, overflowCount } = useMemo(() => {
|
const { visibleUsers, overflowCount } = useMemo(() => {
|
||||||
if (workflowUsers.length === 0) {
|
if (workflowUsers.length === 0) {
|
||||||
@@ -116,7 +118,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
const visible = workflowUsers.slice(0, maxVisible)
|
const visible = workflowUsers.slice(0, maxVisible)
|
||||||
const overflow = Math.max(0, workflowUsers.length - maxVisible)
|
const overflow = Math.max(0, workflowUsers.length - maxVisible)
|
||||||
|
|
||||||
return { visibleUsers: visible, overflowCount: overflow }
|
// Reverse so rightmost avatars stay stable as new ones are revealed on the left
|
||||||
|
return { visibleUsers: [...visible].reverse(), overflowCount: overflow }
|
||||||
}, [workflowUsers, maxVisible])
|
}, [workflowUsers, maxVisible])
|
||||||
|
|
||||||
if (visibleUsers.length === 0) {
|
if (visibleUsers.length === 0) {
|
||||||
@@ -139,9 +142,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleUsers.map((user, index) => (
|
{visibleUsers.map((user, index) => (
|
||||||
<UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
|
<UserAvatar key={user.socketId} user={user} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ export function WorkflowItem({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'min-w-0 flex-1 truncate font-medium',
|
'min-w-0 truncate font-medium',
|
||||||
active
|
active
|
||||||
? 'text-[var(--text-primary)]'
|
? 'text-[var(--text-primary)]'
|
||||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||||
|
|||||||
@@ -1,30 +1,5 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { WorkflowIcon } from '@/components/icons'
|
import { WorkflowIcon } from '@/components/icons'
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
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 = {
|
export const WorkflowBlock: BlockConfig = {
|
||||||
type: 'workflow',
|
type: 'workflow',
|
||||||
@@ -38,8 +13,7 @@ export const WorkflowBlock: BlockConfig = {
|
|||||||
{
|
{
|
||||||
id: 'workflowId',
|
id: 'workflowId',
|
||||||
title: 'Select Workflow',
|
title: 'Select Workflow',
|
||||||
type: 'combobox',
|
type: 'workflow-selector',
|
||||||
options: getAvailableWorkflows,
|
|
||||||
placeholder: 'Search workflows...',
|
placeholder: 'Search workflows...',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
import { WorkflowIcon } from '@/components/icons'
|
import { WorkflowIcon } from '@/components/icons'
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
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 = {
|
export const WorkflowInputBlock: BlockConfig = {
|
||||||
type: 'workflow_input',
|
type: 'workflow_input',
|
||||||
@@ -25,21 +12,19 @@ export const WorkflowInputBlock: BlockConfig = {
|
|||||||
`,
|
`,
|
||||||
category: 'blocks',
|
category: 'blocks',
|
||||||
docsLink: 'https://docs.sim.ai/blocks/workflow',
|
docsLink: 'https://docs.sim.ai/blocks/workflow',
|
||||||
bgColor: '#6366F1', // Indigo - modern and professional
|
bgColor: '#6366F1',
|
||||||
icon: WorkflowIcon,
|
icon: WorkflowIcon,
|
||||||
subBlocks: [
|
subBlocks: [
|
||||||
{
|
{
|
||||||
id: 'workflowId',
|
id: 'workflowId',
|
||||||
title: 'Select Workflow',
|
title: 'Select Workflow',
|
||||||
type: 'combobox',
|
type: 'workflow-selector',
|
||||||
options: getAvailableWorkflows,
|
|
||||||
placeholder: 'Search workflows...',
|
placeholder: 'Search workflows...',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
// Renders dynamic mapping UI based on selected child workflow's Start trigger inputFormat
|
|
||||||
{
|
{
|
||||||
id: 'inputMapping',
|
id: 'inputMapping',
|
||||||
title: 'Input Mapping',
|
title: 'Inputs',
|
||||||
type: 'input-mapping',
|
type: 'input-mapping',
|
||||||
description:
|
description:
|
||||||
"Map fields defined in the child workflow's Start block to variables/values in this workflow.",
|
"Map fields defined in the child workflow's Start block to variables/values in this workflow.",
|
||||||
|
|||||||
@@ -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
|
* If workspaceId is not provided, extracts it from the current URL
|
||||||
*/
|
*/
|
||||||
export function getCustomTool(
|
export function getCustomTool(
|
||||||
toolId: string,
|
identifier: string,
|
||||||
workspaceId?: string
|
workspaceId?: string
|
||||||
): CustomToolDefinition | undefined {
|
): CustomToolDefinition | undefined {
|
||||||
const tools = getCustomTools(workspaceId)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||||
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
|
|
||||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||||
import {
|
import {
|
||||||
createOptimisticMutationHandlers,
|
createOptimisticMutationHandlers,
|
||||||
@@ -26,34 +25,35 @@ export const workflowKeys = {
|
|||||||
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
|
||||||
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
|
||||||
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
|
||||||
inputFields: (workflowId: string | undefined) =>
|
state: (workflowId: string | undefined) =>
|
||||||
[...workflowKeys.all, 'inputFields', workflowId ?? ''] as const,
|
[...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}`)
|
const response = await fetch(`/api/workflows/${workflowId}`)
|
||||||
if (!response.ok) throw new Error('Failed to fetch workflow')
|
if (!response.ok) throw new Error('Failed to fetch workflow')
|
||||||
const { data } = await response.json()
|
const { data } = await response.json()
|
||||||
return extractInputFieldsFromBlocks(data?.state?.blocks)
|
return data?.state ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch workflow input fields for configuration.
|
* Hook to fetch workflow state.
|
||||||
* Uses React Query for caching and deduplication.
|
* 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
|
* @param workflowId - The workflow ID to fetch state for
|
||||||
* @returns Query result with input fields array
|
* @returns Query result with workflow state
|
||||||
*/
|
*/
|
||||||
export function useWorkflowInputFields(workflowId: string | undefined) {
|
export function useWorkflowState(workflowId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: workflowKeys.inputFields(workflowId),
|
queryKey: workflowKeys.state(workflowId),
|
||||||
queryFn: () => fetchWorkflowInputFields(workflowId!),
|
queryFn: () => fetchWorkflowState(workflowId!),
|
||||||
enabled: Boolean(workflowId),
|
enabled: Boolean(workflowId),
|
||||||
staleTime: 0,
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
refetchOnMount: 'always',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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> {
|
async function fetchChildDeploymentStatus(workflowId: string): Promise<ChildDeploymentStatus> {
|
||||||
const statusRes = await fetch(`/api/workflows/${workflowId}/status`, {
|
const fetchOptions = {
|
||||||
cache: 'no-store',
|
cache: 'no-store' as const,
|
||||||
headers: { 'Cache-Control': 'no-cache' },
|
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) {
|
if (!statusRes.ok) {
|
||||||
throw new Error('Failed to fetch workflow status')
|
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 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
|
let activeVersion: number | null = null
|
||||||
if (deploymentsRes.ok) {
|
if (deploymentsRes.ok) {
|
||||||
const deploymentsJson = await deploymentsRes.json()
|
const deploymentsJson = await deploymentsRes.json()
|
||||||
@@ -580,7 +581,7 @@ export function useChildDeploymentStatus(workflowId: string | undefined) {
|
|||||||
queryKey: workflowKeys.deploymentStatus(workflowId),
|
queryKey: workflowKeys.deploymentStatus(workflowId),
|
||||||
queryFn: () => fetchChildDeploymentStatus(workflowId!),
|
queryFn: () => fetchChildDeploymentStatus(workflowId!),
|
||||||
enabled: Boolean(workflowId),
|
enabled: Boolean(workflowId),
|
||||||
staleTime: 30 * 1000, // 30 seconds
|
staleTime: 0,
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
SelectorOption,
|
SelectorOption,
|
||||||
SelectorQueryArgs,
|
SelectorQueryArgs,
|
||||||
} from '@/hooks/selectors/types'
|
} from '@/hooks/selectors/types'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
const SELECTOR_STALE = 60 * 1000
|
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 {
|
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type SelectorKey =
|
|||||||
| 'webflow.sites'
|
| 'webflow.sites'
|
||||||
| 'webflow.collections'
|
| 'webflow.collections'
|
||||||
| 'webflow.items'
|
| 'webflow.items'
|
||||||
|
| 'sim.workflows'
|
||||||
|
|
||||||
export interface SelectorOption {
|
export interface SelectorOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -52,6 +53,7 @@ export interface SelectorContext {
|
|||||||
siteId?: string
|
siteId?: string
|
||||||
collectionId?: string
|
collectionId?: string
|
||||||
spreadsheetId?: string
|
spreadsheetId?: string
|
||||||
|
excludeWorkflowId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectorQueryArgs {
|
export interface SelectorQueryArgs {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
|||||||
|
|
||||||
const logger = createLogger('Auth')
|
const logger = createLogger('Auth')
|
||||||
|
|
||||||
|
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
|
||||||
|
|
||||||
const validStripeKey = env.STRIPE_SECRET_KEY
|
const validStripeKey = env.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
let stripeClient = null
|
let stripeClient = null
|
||||||
@@ -187,6 +189,10 @@ export const auth = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
|
||||||
|
? getMicrosoftRefreshTokenExpiry()
|
||||||
|
: account.refreshTokenExpiresAt
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(schema.account)
|
.update(schema.account)
|
||||||
.set({
|
.set({
|
||||||
@@ -195,7 +201,7 @@ export const auth = betterAuth({
|
|||||||
refreshToken: account.refreshToken,
|
refreshToken: account.refreshToken,
|
||||||
idToken: account.idToken,
|
idToken: account.idToken,
|
||||||
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
||||||
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
|
refreshTokenExpiresAt,
|
||||||
scope: scopeToStore,
|
scope: scopeToStore,
|
||||||
updatedAt: new Date(),
|
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
|
// Sync webhooks for credential sets after connecting a new credential
|
||||||
const requestId = crypto.randomUUID().slice(0, 8)
|
const requestId = crypto.randomUUID().slice(0, 8)
|
||||||
const userMemberships = await db
|
const userMemberships = await db
|
||||||
@@ -387,7 +400,6 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
allowDifferentEmails: true,
|
allowDifferentEmails: true,
|
||||||
trustedProviders: [
|
trustedProviders: [
|
||||||
// Standard OAuth providers
|
|
||||||
'google',
|
'google',
|
||||||
'github',
|
'github',
|
||||||
'email-password',
|
'email-password',
|
||||||
@@ -403,8 +415,32 @@ export const auth = betterAuth({
|
|||||||
'hubspot',
|
'hubspot',
|
||||||
'linkedin',
|
'linkedin',
|
||||||
'spotify',
|
'spotify',
|
||||||
|
'google-email',
|
||||||
// Common SSO provider patterns
|
'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,
|
...SSO_TRUSTED_PROVIDERS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
|
|||||||
interface OutputFieldSchema {
|
interface OutputFieldSchema {
|
||||||
type: string
|
type: string
|
||||||
description?: string
|
description?: string
|
||||||
|
properties?: Record<string, OutputFieldSchema>
|
||||||
|
items?: { type: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,6 +258,42 @@ function mapSubBlockTypeToSchemaType(type: string): string {
|
|||||||
return typeMap[type] || '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
|
* Extracts trigger outputs from the first available trigger
|
||||||
*/
|
*/
|
||||||
@@ -272,15 +310,7 @@ function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSche
|
|||||||
const trigger = getTrigger(triggerId)
|
const trigger = getTrigger(triggerId)
|
||||||
if (trigger.outputs) {
|
if (trigger.outputs) {
|
||||||
for (const [key, def] of Object.entries(trigger.outputs)) {
|
for (const [key, def] of Object.entries(trigger.outputs)) {
|
||||||
if (typeof def === 'string') {
|
outputs[key] = extractOutputField(def)
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,11 +342,7 @@ function extractOutputs(
|
|||||||
const tool = toolsRegistry[toolId]
|
const tool = toolsRegistry[toolId]
|
||||||
if (tool?.outputs) {
|
if (tool?.outputs) {
|
||||||
for (const [key, def] of Object.entries(tool.outputs)) {
|
for (const [key, def] of Object.entries(tool.outputs)) {
|
||||||
const typedDef = def as { type: string; description?: string }
|
outputs[key] = extractOutputField(def)
|
||||||
outputs[key] = {
|
|
||||||
type: typedDef.type || 'any',
|
|
||||||
description: typedDef.description,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return outputs
|
return outputs
|
||||||
}
|
}
|
||||||
@@ -329,15 +355,7 @@ function extractOutputs(
|
|||||||
// Use block-level outputs
|
// Use block-level outputs
|
||||||
if (blockConfig.outputs) {
|
if (blockConfig.outputs) {
|
||||||
for (const [key, def] of Object.entries(blockConfig.outputs)) {
|
for (const [key, def] of Object.entries(blockConfig.outputs)) {
|
||||||
if (typeof def === 'string') {
|
outputs[key] = extractOutputField(def)
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2468,16 +2468,17 @@ async function validateWorkflowSelectorIds(
|
|||||||
const result = await validateSelectorIds(selector.selectorType, selector.value, context)
|
const result = await validateSelectorIds(selector.selectorType, selector.value, context)
|
||||||
|
|
||||||
if (result.invalid.length > 0) {
|
if (result.invalid.length > 0) {
|
||||||
|
// Include warning info (like available credentials) in the error message for better LLM feedback
|
||||||
|
const warningInfo = result.warning ? `. ${result.warning}` : ''
|
||||||
errors.push({
|
errors.push({
|
||||||
blockId: selector.blockId,
|
blockId: selector.blockId,
|
||||||
blockType: selector.blockType,
|
blockType: selector.blockType,
|
||||||
field: selector.fieldName,
|
field: selector.fieldName,
|
||||||
value: selector.value,
|
value: selector.value,
|
||||||
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist`,
|
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`,
|
||||||
})
|
})
|
||||||
}
|
} else if (result.warning) {
|
||||||
|
// Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors)
|
||||||
if (result.warning) {
|
|
||||||
logger.warn(result.warning, {
|
logger.warn(result.warning, {
|
||||||
blockId: selector.blockId,
|
blockId: selector.blockId,
|
||||||
fieldName: selector.fieldName,
|
fieldName: selector.fieldName,
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ export async function validateSelectorIds(
|
|||||||
.from(account)
|
.from(account)
|
||||||
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
|
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
|
||||||
existingIds = results.map((r) => r.id)
|
existingIds = results.map((r) => r.id)
|
||||||
|
|
||||||
|
// If any IDs are invalid, fetch user's available credentials to include in error message
|
||||||
|
const existingSet = new Set(existingIds)
|
||||||
|
const invalidIds = idsArray.filter((id) => !existingSet.has(id))
|
||||||
|
if (invalidIds.length > 0) {
|
||||||
|
// Fetch all of the user's credentials to provide helpful feedback
|
||||||
|
const allUserCredentials = await db
|
||||||
|
.select({ id: account.id, providerId: account.providerId })
|
||||||
|
.from(account)
|
||||||
|
.where(eq(account.userId, context.userId))
|
||||||
|
|
||||||
|
const availableCredentials = allUserCredentials
|
||||||
|
.map((c) => `${c.id} (${c.providerId})`)
|
||||||
|
.join(', ')
|
||||||
|
const noCredentialsMessage = 'User has no credentials configured.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: existingIds,
|
||||||
|
invalid: invalidIds,
|
||||||
|
warning:
|
||||||
|
allUserCredentials.length > 0
|
||||||
|
? `Available credentials for this user: ${availableCredentials}`
|
||||||
|
: noCredentialsMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './microsoft'
|
||||||
export * from './oauth'
|
export * from './oauth'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
|||||||
19
apps/sim/lib/oauth/microsoft.ts
Normal file
19
apps/sim/lib/oauth/microsoft.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -835,6 +835,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
useBasicAuth: true,
|
useBasicAuth: true,
|
||||||
|
supportsRefreshTokenRotation: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'confluence': {
|
case 'confluence': {
|
||||||
@@ -883,6 +884,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
useBasicAuth: false,
|
useBasicAuth: false,
|
||||||
|
supportsRefreshTokenRotation: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'microsoft':
|
case 'microsoft':
|
||||||
@@ -910,6 +912,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
useBasicAuth: true,
|
useBasicAuth: true,
|
||||||
|
supportsRefreshTokenRotation: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'dropbox': {
|
case 'dropbox': {
|
||||||
|
|||||||
@@ -771,12 +771,50 @@ function deepClone<T>(obj: T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively masks credential IDs in any value (string, object, or array).
|
||||||
|
* Used during serialization to ensure sensitive IDs are never persisted.
|
||||||
|
*/
|
||||||
|
function maskCredentialIdsInValue(value: any, credentialIds: Set<string>): any {
|
||||||
|
if (!value || credentialIds.size === 0) return value
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
let masked = value
|
||||||
|
// Sort by length descending to mask longer IDs first
|
||||||
|
const sortedIds = Array.from(credentialIds).sort((a, b) => b.length - a.length)
|
||||||
|
for (const id of sortedIds) {
|
||||||
|
if (id && masked.includes(id)) {
|
||||||
|
masked = masked.split(id).join('••••••••')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => maskCredentialIdsInValue(item, credentialIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const masked: any = {}
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
masked[key] = maskCredentialIdsInValue(value[key], credentialIds)
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes messages for database storage.
|
* Serializes messages for database storage.
|
||||||
* Deep clones all fields to ensure proper JSON serialization.
|
* Deep clones all fields to ensure proper JSON serialization.
|
||||||
|
* Masks sensitive credential IDs before persisting.
|
||||||
* This ensures they render identically when loaded back.
|
* This ensures they render identically when loaded back.
|
||||||
*/
|
*/
|
||||||
function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
||||||
|
// Get credential IDs to mask
|
||||||
|
const credentialIds = useCopilotStore.getState().sensitiveCredentialIds
|
||||||
|
|
||||||
const result = messages
|
const result = messages
|
||||||
.map((msg) => {
|
.map((msg) => {
|
||||||
// Deep clone the entire message to ensure all nested data is serializable
|
// Deep clone the entire message to ensure all nested data is serializable
|
||||||
@@ -824,7 +862,8 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
|||||||
serialized.errorType = msg.errorType
|
serialized.errorType = msg.errorType
|
||||||
}
|
}
|
||||||
|
|
||||||
return serialized
|
// Mask credential IDs in the serialized message before persisting
|
||||||
|
return maskCredentialIdsInValue(serialized, credentialIds)
|
||||||
})
|
})
|
||||||
.filter((msg) => {
|
.filter((msg) => {
|
||||||
// Filter out empty assistant messages
|
// Filter out empty assistant messages
|
||||||
@@ -1320,7 +1359,16 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
typeof def.hasInterrupt === 'function'
|
typeof def.hasInterrupt === 'function'
|
||||||
? !!def.hasInterrupt(args || {})
|
? !!def.hasInterrupt(args || {})
|
||||||
: !!def.hasInterrupt
|
: !!def.hasInterrupt
|
||||||
if (!hasInterrupt && typeof def.execute === 'function') {
|
// Check if tool is auto-allowed - if so, execute even if it has an interrupt
|
||||||
|
const { autoAllowedTools } = get()
|
||||||
|
const isAutoAllowed = name ? autoAllowedTools.includes(name) : false
|
||||||
|
if ((!hasInterrupt || isAutoAllowed) && typeof def.execute === 'function') {
|
||||||
|
if (isAutoAllowed && hasInterrupt) {
|
||||||
|
logger.info('[toolCallsById] Auto-executing tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' })
|
const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' })
|
||||||
// Defer executing transition by a tick to let pending render
|
// Defer executing transition by a tick to let pending render
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1426,11 +1474,23 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
logger.warn('tool_call registry auto-exec check failed', { id, name, error: e })
|
logger.warn('tool_call registry auto-exec check failed', { id, name, error: e })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Class-based auto-exec for non-interrupt tools
|
// Class-based auto-exec for non-interrupt tools or auto-allowed tools
|
||||||
try {
|
try {
|
||||||
const inst = getClientTool(id) as any
|
const inst = getClientTool(id) as any
|
||||||
const hasInterrupt = !!inst?.getInterruptDisplays?.()
|
const hasInterrupt = !!inst?.getInterruptDisplays?.()
|
||||||
if (!hasInterrupt && typeof inst?.execute === 'function') {
|
// Check if tool is auto-allowed - if so, execute even if it has an interrupt
|
||||||
|
const { autoAllowedTools: classAutoAllowed } = get()
|
||||||
|
const isClassAutoAllowed = name ? classAutoAllowed.includes(name) : false
|
||||||
|
if (
|
||||||
|
(!hasInterrupt || isClassAutoAllowed) &&
|
||||||
|
(typeof inst?.execute === 'function' || typeof inst?.handleAccept === 'function')
|
||||||
|
) {
|
||||||
|
if (isClassAutoAllowed && hasInterrupt) {
|
||||||
|
logger.info('[toolCallsById] Auto-executing class tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Guard against duplicate execution - check if already executing or terminal
|
// Guard against duplicate execution - check if already executing or terminal
|
||||||
const currentState = get().toolCallsById[id]?.state
|
const currentState = get().toolCallsById[id]?.state
|
||||||
@@ -1449,7 +1509,12 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
|
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await inst.execute(args || {})
|
// Use handleAccept for tools with interrupts, execute for others
|
||||||
|
if (hasInterrupt && typeof inst?.handleAccept === 'function') {
|
||||||
|
await inst.handleAccept(args || {})
|
||||||
|
} else {
|
||||||
|
await inst.execute(args || {})
|
||||||
|
}
|
||||||
// Success/error will be synced via registerToolStateSync
|
// Success/error will be synced via registerToolStateSync
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -1474,20 +1539,35 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Integration tools: Stay in pending state until user confirms via buttons
|
// Integration tools: Check auto-allowed or stay in pending state until user confirms
|
||||||
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
|
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
|
||||||
// Only relevant if mode is 'build' (agent)
|
// Only relevant if mode is 'build' (agent)
|
||||||
const { mode, workflowId } = get()
|
const { mode, workflowId, autoAllowedTools, executeIntegrationTool } = get()
|
||||||
if (mode === 'build' && workflowId) {
|
if (mode === 'build' && workflowId) {
|
||||||
// Check if tool was NOT found in client registry
|
// Check if tool was NOT found in client registry
|
||||||
const def = name ? getTool(name) : undefined
|
const def = name ? getTool(name) : undefined
|
||||||
const inst = getClientTool(id) as any
|
const inst = getClientTool(id) as any
|
||||||
if (!def && !inst && name) {
|
if (!def && !inst && name) {
|
||||||
// Integration tools stay in pending state until user confirms
|
// Check if this integration tool is auto-allowed - if so, execute it immediately
|
||||||
logger.info('[build mode] Integration tool awaiting user confirmation', {
|
if (autoAllowedTools.includes(name)) {
|
||||||
id,
|
logger.info('[build mode] Auto-executing integration tool (auto-allowed)', { id, name })
|
||||||
name,
|
// Defer to allow pending state to render briefly
|
||||||
})
|
setTimeout(() => {
|
||||||
|
executeIntegrationTool(id).catch((err) => {
|
||||||
|
logger.error('[build mode] Auto-execute integration tool failed', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
error: err,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
// Integration tools stay in pending state until user confirms
|
||||||
|
logger.info('[build mode] Integration tool awaiting user confirmation', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1976,6 +2056,10 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
|
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
|
||||||
|
// Check if tool is auto-allowed
|
||||||
|
const { autoAllowedTools: subAgentAutoAllowed } = get()
|
||||||
|
const isSubAgentAutoAllowed = name ? subAgentAutoAllowed.includes(name) : false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const def = getTool(name)
|
const def = getTool(name)
|
||||||
if (def) {
|
if (def) {
|
||||||
@@ -1983,8 +2067,15 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
typeof def.hasInterrupt === 'function'
|
typeof def.hasInterrupt === 'function'
|
||||||
? !!def.hasInterrupt(args || {})
|
? !!def.hasInterrupt(args || {})
|
||||||
: !!def.hasInterrupt
|
: !!def.hasInterrupt
|
||||||
if (!hasInterrupt) {
|
// Auto-execute if no interrupt OR if auto-allowed
|
||||||
// Auto-execute tools without interrupts - non-blocking
|
if (!hasInterrupt || isSubAgentAutoAllowed) {
|
||||||
|
if (isSubAgentAutoAllowed && hasInterrupt) {
|
||||||
|
logger.info('[SubAgent] Auto-executing tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Auto-execute tools - non-blocking
|
||||||
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
|
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => def.execute(ctx, args || {}))
|
.then(() => def.execute(ctx, args || {}))
|
||||||
@@ -2001,9 +2092,22 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
const instance = getClientTool(id)
|
const instance = getClientTool(id)
|
||||||
if (instance) {
|
if (instance) {
|
||||||
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
|
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
|
||||||
if (!hasInterruptDisplays) {
|
// Auto-execute if no interrupt OR if auto-allowed
|
||||||
|
if (!hasInterruptDisplays || isSubAgentAutoAllowed) {
|
||||||
|
if (isSubAgentAutoAllowed && hasInterruptDisplays) {
|
||||||
|
logger.info('[SubAgent] Auto-executing class tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => instance.execute(args || {}))
|
.then(() => {
|
||||||
|
// Use handleAccept for tools with interrupts, execute for others
|
||||||
|
if (hasInterruptDisplays && typeof instance.handleAccept === 'function') {
|
||||||
|
return instance.handleAccept(args || {})
|
||||||
|
}
|
||||||
|
return instance.execute(args || {})
|
||||||
|
})
|
||||||
.catch((execErr: any) => {
|
.catch((execErr: any) => {
|
||||||
logger.error('[SubAgent] Class tool execution failed', {
|
logger.error('[SubAgent] Class tool execution failed', {
|
||||||
id,
|
id,
|
||||||
@@ -2232,6 +2336,7 @@ const initialState = {
|
|||||||
autoAllowedTools: [] as string[],
|
autoAllowedTools: [] as string[],
|
||||||
messageQueue: [] as import('./types').QueuedMessage[],
|
messageQueue: [] as import('./types').QueuedMessage[],
|
||||||
suppressAbortContinueOption: false,
|
suppressAbortContinueOption: false,
|
||||||
|
sensitiveCredentialIds: new Set<string>(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCopilotStore = create<CopilotStore>()(
|
export const useCopilotStore = create<CopilotStore>()(
|
||||||
@@ -2614,6 +2719,12 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load sensitive credential IDs for masking before streaming starts
|
||||||
|
await get().loadSensitiveCredentialIds()
|
||||||
|
|
||||||
|
// Ensure auto-allowed tools are loaded before tool calls arrive
|
||||||
|
await get().loadAutoAllowedTools()
|
||||||
|
|
||||||
let newMessages: CopilotMessage[]
|
let newMessages: CopilotMessage[]
|
||||||
if (revertState) {
|
if (revertState) {
|
||||||
const currentMessages = get().messages
|
const currentMessages = get().messages
|
||||||
@@ -3676,6 +3787,16 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
|
|
||||||
const { id, name, params } = toolCall
|
const { id, name, params } = toolCall
|
||||||
|
|
||||||
|
// Guard against double execution - skip if already executing or in terminal state
|
||||||
|
if (toolCall.state === ClientToolCallState.executing || isTerminalState(toolCall.state)) {
|
||||||
|
logger.info('[executeIntegrationTool] Skipping - already executing or terminal', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
state: toolCall.state,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Set to executing state
|
// Set to executing state
|
||||||
const executingMap = { ...get().toolCallsById }
|
const executingMap = { ...get().toolCallsById }
|
||||||
executingMap[id] = {
|
executingMap[id] = {
|
||||||
@@ -3824,6 +3945,46 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
set({ autoAllowedTools: data.autoAllowedTools || [] })
|
set({ autoAllowedTools: data.autoAllowedTools || [] })
|
||||||
logger.info('[AutoAllowedTools] Added tool', { toolId })
|
logger.info('[AutoAllowedTools] Added tool', { toolId })
|
||||||
|
|
||||||
|
// Auto-execute all pending tools of the same type
|
||||||
|
const { toolCallsById, executeIntegrationTool } = get()
|
||||||
|
const pendingToolCalls = Object.values(toolCallsById).filter(
|
||||||
|
(tc) => tc.name === toolId && tc.state === ClientToolCallState.pending
|
||||||
|
)
|
||||||
|
if (pendingToolCalls.length > 0) {
|
||||||
|
const isIntegrationTool = !CLASS_TOOL_METADATA[toolId]
|
||||||
|
logger.info('[AutoAllowedTools] Auto-executing pending tools', {
|
||||||
|
toolId,
|
||||||
|
count: pendingToolCalls.length,
|
||||||
|
isIntegrationTool,
|
||||||
|
})
|
||||||
|
for (const tc of pendingToolCalls) {
|
||||||
|
if (isIntegrationTool) {
|
||||||
|
// Integration tools use executeIntegrationTool
|
||||||
|
executeIntegrationTool(tc.id).catch((err) => {
|
||||||
|
logger.error('[AutoAllowedTools] Auto-execute pending integration tool failed', {
|
||||||
|
toolCallId: tc.id,
|
||||||
|
toolId,
|
||||||
|
error: err,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Client tools with interrupts use handleAccept
|
||||||
|
const inst = getClientTool(tc.id) as any
|
||||||
|
if (inst && typeof inst.handleAccept === 'function') {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => inst.handleAccept(tc.params || {}))
|
||||||
|
.catch((err: any) => {
|
||||||
|
logger.error('[AutoAllowedTools] Auto-execute pending client tool failed', {
|
||||||
|
toolCallId: tc.id,
|
||||||
|
toolId,
|
||||||
|
error: err,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
|
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
|
||||||
@@ -3853,6 +4014,57 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
return autoAllowedTools.includes(toolId)
|
return autoAllowedTools.includes(toolId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Credential masking
|
||||||
|
loadSensitiveCredentialIds: async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ toolName: 'get_credentials', payload: {} }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn('[loadSensitiveCredentialIds] Failed to fetch credentials', {
|
||||||
|
status: res.status,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
// Credentials are at result.oauth.connected.credentials
|
||||||
|
const credentials = json?.result?.oauth?.connected?.credentials || []
|
||||||
|
logger.info('[loadSensitiveCredentialIds] Response', {
|
||||||
|
hasResult: !!json?.result,
|
||||||
|
credentialCount: credentials.length,
|
||||||
|
})
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const cred of credentials) {
|
||||||
|
if (cred?.id) {
|
||||||
|
ids.add(cred.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ sensitiveCredentialIds: ids })
|
||||||
|
logger.info('[loadSensitiveCredentialIds] Loaded credential IDs', {
|
||||||
|
count: ids.size,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[loadSensitiveCredentialIds] Error loading credentials', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
maskCredentialValue: (value: string) => {
|
||||||
|
const { sensitiveCredentialIds } = get()
|
||||||
|
if (!value || sensitiveCredentialIds.size === 0) return value
|
||||||
|
|
||||||
|
let masked = value
|
||||||
|
// Sort by length descending to mask longer IDs first
|
||||||
|
const sortedIds = Array.from(sensitiveCredentialIds).sort((a, b) => b.length - a.length)
|
||||||
|
for (const id of sortedIds) {
|
||||||
|
if (id && masked.includes(id)) {
|
||||||
|
masked = masked.split(id).join('••••••••')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
},
|
||||||
|
|
||||||
// Message queue actions
|
// Message queue actions
|
||||||
addToQueue: (message, options) => {
|
addToQueue: (message, options) => {
|
||||||
const queuedMessage: import('./types').QueuedMessage = {
|
const queuedMessage: import('./types').QueuedMessage = {
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ export interface CopilotState {
|
|||||||
|
|
||||||
// Message queue for messages sent while another is in progress
|
// Message queue for messages sent while another is in progress
|
||||||
messageQueue: QueuedMessage[]
|
messageQueue: QueuedMessage[]
|
||||||
|
|
||||||
|
// Credential IDs to mask in UI (for sensitive data protection)
|
||||||
|
sensitiveCredentialIds: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CopilotActions {
|
export interface CopilotActions {
|
||||||
@@ -235,6 +238,10 @@ export interface CopilotActions {
|
|||||||
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
||||||
isToolAutoAllowed: (toolId: string) => boolean
|
isToolAutoAllowed: (toolId: string) => boolean
|
||||||
|
|
||||||
|
// Credential masking
|
||||||
|
loadSensitiveCredentialIds: () => Promise<void>
|
||||||
|
maskCredentialValue: (value: string) => string
|
||||||
|
|
||||||
// Message queue actions
|
// Message queue actions
|
||||||
addToQueue: (
|
addToQueue: (
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -643,13 +643,22 @@ async function executeToolRequest(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const responseHeaders = new Headers(secureResponse.headers.toRecord())
|
const responseHeaders = new Headers(secureResponse.headers.toRecord())
|
||||||
const bodyBuffer = await secureResponse.arrayBuffer()
|
const nullBodyStatuses = new Set([101, 204, 205, 304])
|
||||||
|
|
||||||
response = new Response(bodyBuffer, {
|
if (nullBodyStatuses.has(secureResponse.status)) {
|
||||||
status: secureResponse.status,
|
response = new Response(null, {
|
||||||
statusText: secureResponse.statusText,
|
status: secureResponse.status,
|
||||||
headers: responseHeaders,
|
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
|
// For non-OK responses, attempt JSON first; if parsing fails, fall back to text
|
||||||
@@ -693,11 +702,9 @@ async function executeToolRequest(
|
|||||||
throw errorToTransform
|
throw errorToTransform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response data once with guard for empty 202 bodies
|
|
||||||
let responseData
|
let responseData
|
||||||
const status = response.status
|
const status = response.status
|
||||||
if (status === 202) {
|
if (status === 202 || status === 204 || status === 205) {
|
||||||
// Many APIs (e.g., Microsoft Graph) return 202 with empty body
|
|
||||||
responseData = { status }
|
responseData = { status }
|
||||||
} else {
|
} else {
|
||||||
if (tool.transformResponse) {
|
if (tool.transformResponse) {
|
||||||
|
|||||||
Reference in New Issue
Block a user