mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
55 Commits
feat/agent
...
v0.6.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b572f1f61 | ||
|
|
b497033795 | ||
|
|
666dc67aa2 | ||
|
|
7af7a225f2 | ||
|
|
228578e282 | ||
|
|
be647469ac | ||
|
|
96b171cf74 | ||
|
|
cdea2404e3 | ||
|
|
ed9a71f0af | ||
|
|
f6975fc0a3 | ||
|
|
59182d5db2 | ||
|
|
b9926df8e0 | ||
|
|
77eafabb63 | ||
|
|
34ea99e99d | ||
|
|
a7f344bca1 | ||
|
|
7b6149dc23 | ||
|
|
b09a073c29 | ||
|
|
8d93c850ba | ||
|
|
83eb3ed211 | ||
|
|
a783b9d4ce | ||
|
|
c78c870fda | ||
|
|
0c80438ede | ||
|
|
41a7d247ea | ||
|
|
092525e8aa | ||
|
|
19442f19e2 | ||
|
|
1731a4d7f0 | ||
|
|
9fcd02fd3b | ||
|
|
ff7b5b528c | ||
|
|
30f2d1a0fc | ||
|
|
4bd0731871 | ||
|
|
4f3bc37fe4 | ||
|
|
84d6fdc423 | ||
|
|
4c12914d35 | ||
|
|
e9bdc57616 | ||
|
|
36612ae42a | ||
|
|
1c2c2c65d4 | ||
|
|
ecd3536a72 | ||
|
|
8c0a2e04b1 | ||
|
|
6586c5ce40 | ||
|
|
3ce947566d | ||
|
|
70c36cb7aa | ||
|
|
f1ec5fe824 | ||
|
|
e07e3c34cc | ||
|
|
0d2e6ff31d | ||
|
|
4fd0989264 | ||
|
|
67f8a687f6 | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
15ace5e63f | ||
|
|
fdca73679d | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -88,8 +87,6 @@ export default function LoginPage({
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
@@ -169,20 +166,6 @@ export default function LoginPage({
|
||||
const safeCallbackUrl = callbackUrl
|
||||
let errorHandled = false
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
@@ -191,11 +174,6 @@ export default function LoginPage({
|
||||
callbackURL: safeCallbackUrl,
|
||||
},
|
||||
{
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
@@ -464,16 +442,6 @@ export default function LoginPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className='absolute'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetSuccessMessage && (
|
||||
<div className='text-[#4CAF50] text-xs'>
|
||||
<p>{resetSuccessMessage}</p>
|
||||
|
||||
@@ -93,6 +93,8 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
|
||||
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
@@ -249,17 +251,30 @@ function SignupFormContent({
|
||||
|
||||
const sanitizedName = trimmedName
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
const widget = turnstileRef.current
|
||||
if (turnstileSiteKey && widget) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
widget.reset()
|
||||
token = await Promise.race([
|
||||
new Promise<string>((resolve, reject) => {
|
||||
captchaResolveRef.current = resolve
|
||||
captchaRejectRef.current = reject
|
||||
widget.execute()
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
captchaResolveRef.current = null
|
||||
captchaRejectRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +493,14 @@ function SignupFormContent({
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className='absolute'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
</div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={(token) => captchaResolveRef.current?.(token)}
|
||||
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
|
||||
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
|
||||
options={{ execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
BlogDropdown,
|
||||
@@ -40,6 +42,12 @@ interface NavbarProps {
|
||||
|
||||
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
|
||||
const brand = getBrandConfig()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const isAuthenticated = Boolean(session?.user?.id)
|
||||
const isBrowsingHome = searchParams.has('home')
|
||||
const useHomeLinks = isAuthenticated || isBrowsingHome
|
||||
const logoHref = useHomeLinks ? '/?home' : '/'
|
||||
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
@@ -92,7 +100,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
<Link href='/' className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<Link href={logoHref} className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
{brand.name}
|
||||
</span>
|
||||
@@ -121,7 +129,9 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
{!logoOnly && (
|
||||
<>
|
||||
<ul className='mt-[0.75px] hidden lg:flex'>
|
||||
{NAV_LINKS.map(({ label, href, external, icon, dropdown }) => {
|
||||
{NAV_LINKS.map(({ label, href: rawHref, external, icon, dropdown }) => {
|
||||
const href =
|
||||
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
|
||||
const hasDropdown = !!dropdown
|
||||
const isActive = hasDropdown && activeDropdown === dropdown
|
||||
const isThisHovered = hoveredLink === label
|
||||
@@ -206,21 +216,38 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
|
||||
<div className='hidden flex-1 lg:block' />
|
||||
|
||||
<div className='hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
'hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex',
|
||||
isSessionPending && 'invisible'
|
||||
)}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Go to app'
|
||||
>
|
||||
Go to App
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
|
||||
@@ -242,30 +269,34 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
)}
|
||||
>
|
||||
<ul className='flex flex-col'>
|
||||
{NAV_LINKS.map(({ label, href, external }) => (
|
||||
<li key={label} className='border-[#2A2A2A] border-b'>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
<ExternalArrowIcon />
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{NAV_LINKS.map(({ label, href: rawHref, external }) => {
|
||||
const href =
|
||||
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
|
||||
return (
|
||||
<li key={label} className='border-[#2A2A2A] border-b'>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
<ExternalArrowIcon />
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li className='border-[#2A2A2A] border-b'>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
@@ -280,23 +311,41 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className='mt-auto flex flex-col gap-[10px] p-[20px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-auto flex flex-col gap-[10px] p-[20px]',
|
||||
isSessionPending && 'invisible'
|
||||
)}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Go to app'
|
||||
>
|
||||
Go to App
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href='/login'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
sizes='(max-width: 768px) 100vw, 450px'
|
||||
priority
|
||||
itemProp='image'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +143,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
className='h-[160px] w-full object-cover'
|
||||
sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
width={600}
|
||||
height={315}
|
||||
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
|
||||
@@ -32,7 +32,6 @@ export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
|
||||
@@ -5,8 +5,9 @@ import { createLogger } from '@sim/logger'
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
@@ -26,6 +27,12 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
const router = useRouter()
|
||||
const brand = useBrandConfig()
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const isAuthenticated = Boolean(session?.user?.id)
|
||||
const isBrowsingHome = searchParams.has('home')
|
||||
const useHomeLinks = isAuthenticated || isBrowsingHome
|
||||
const logoHref = useHomeLinks ? '/?home' : '/'
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'landing') return
|
||||
@@ -72,7 +79,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/?from=nav#pricing'
|
||||
href={useHomeLinks ? '/?home#pricing' : '/#pricing'}
|
||||
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
scroll={true}
|
||||
>
|
||||
@@ -124,7 +131,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
<div className='flex items-center gap-[34px]'>
|
||||
<Link href='/?from=nav' aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<Link href={logoHref} aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
{brand.name} Home
|
||||
</span>
|
||||
@@ -162,45 +169,70 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
|
||||
{/* Auth Buttons - show only when hosted, regardless of variant */}
|
||||
{!hideAuthButtons && isHosted && (
|
||||
<div className='flex items-center justify-center gap-[16px] pt-[1.5px]'>
|
||||
<button
|
||||
onClick={handleLoginClick}
|
||||
onMouseEnter={() => setIsLoginHovered(true)}
|
||||
onMouseLeave={() => setIsLoginHovered(false)}
|
||||
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
|
||||
type='button'
|
||||
aria-label='Log in to your account'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Log in
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isLoginHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center justify-center gap-[16px] pt-[1.5px]${isSessionPending ? ' invisible' : ''}`}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
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='Go to app'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Go to App
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Link
|
||||
href='/signup'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
prefetch={true}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Get started
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleLoginClick}
|
||||
onMouseEnter={() => setIsLoginHovered(true)}
|
||||
onMouseLeave={() => setIsLoginHovered(false)}
|
||||
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
|
||||
type='button'
|
||||
aria-label='Log in to your account'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Log in
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isLoginHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Link
|
||||
href='/signup'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
prefetch={true}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Get started
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -111,7 +111,7 @@ function buildFAQs(integration: Integration): FAQItem[] {
|
||||
? [
|
||||
{
|
||||
question: `How do I trigger a Sim workflow from ${name} automatically?`,
|
||||
answer: `In your Sim workflow, switch the ${name} block to Trigger mode and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
|
||||
answer: `Add a ${name} trigger block to your workflow and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
|
||||
},
|
||||
{
|
||||
question: `What data does Sim receive when a ${name} event triggers a workflow?`,
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AppSession = {
|
||||
id?: string
|
||||
userId?: string
|
||||
activeOrganizationId?: string
|
||||
impersonatedBy?: string | null
|
||||
}
|
||||
} | null
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { abortActiveStream } from '@/lib/copilot/chat-streaming'
|
||||
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
|
||||
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat-streaming'
|
||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
@@ -12,11 +17,48 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
|
||||
let chatId = typeof body.chatId === 'string' ? body.chatId : ''
|
||||
|
||||
if (!streamId) {
|
||||
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const aborted = abortActiveStream(streamId)
|
||||
if (!chatId) {
|
||||
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch(() => null)
|
||||
if (run?.chatId) {
|
||||
chatId = run.chatId
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), GO_EXPLICIT_ABORT_TIMEOUT_MS)
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
messageId: streamId,
|
||||
userId: authenticatedUserId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
}),
|
||||
}).finally(() => clearTimeout(timeout))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Explicit abort marker request failed: ${response.status}`)
|
||||
}
|
||||
} catch {
|
||||
// best effort: local abort should still proceed even if Go marker fails
|
||||
}
|
||||
|
||||
const aborted = await abortActiveStream(streamId)
|
||||
if (chatId) {
|
||||
await waitForPendingChatStream(chatId, GO_EXPLICIT_ABORT_TIMEOUT_MS + 1000, streamId).catch(
|
||||
() => false
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ aborted })
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import {
|
||||
acquirePendingChatStream,
|
||||
createSSEStream,
|
||||
releasePendingChatStream,
|
||||
requestChatTitle,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
@@ -16,6 +18,7 @@ import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
|
||||
import { resolveActiveResourceContext } from '@/lib/copilot/process-contents'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -44,6 +47,13 @@ const FileAttachmentSchema = z.object({
|
||||
size: z.number(),
|
||||
})
|
||||
|
||||
const ResourceAttachmentSchema = z.object({
|
||||
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const ChatMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
userMessageId: z.string().optional(),
|
||||
@@ -58,6 +68,7 @@ const ChatMessageSchema = z.object({
|
||||
stream: z.boolean().optional().default(true),
|
||||
implicitFeedback: z.string().optional(),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
resourceAttachments: z.array(ResourceAttachmentSchema).optional(),
|
||||
provider: z.string().optional(),
|
||||
contexts: z
|
||||
.array(
|
||||
@@ -98,6 +109,10 @@ const ChatMessageSchema = z.object({
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
let actualChatId: string | undefined
|
||||
let pendingChatStreamAcquired = false
|
||||
let pendingChatStreamHandedOff = false
|
||||
let pendingChatStreamID: string | undefined
|
||||
|
||||
try {
|
||||
// Get session to access user information including name
|
||||
@@ -124,6 +139,7 @@ export async function POST(req: NextRequest) {
|
||||
stream,
|
||||
implicitFeedback,
|
||||
fileAttachments,
|
||||
resourceAttachments,
|
||||
provider,
|
||||
contexts,
|
||||
commands,
|
||||
@@ -189,7 +205,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
actualChatId = chatId
|
||||
const selectedModel = model || 'claude-opus-4-6'
|
||||
|
||||
if (chatId || createNewChat) {
|
||||
@@ -241,6 +257,39 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(resourceAttachments) &&
|
||||
resourceAttachments.length > 0 &&
|
||||
resolvedWorkspaceId
|
||||
) {
|
||||
const results = await Promise.allSettled(
|
||||
resourceAttachments.map(async (r) => {
|
||||
const ctx = await resolveActiveResourceContext(
|
||||
r.type,
|
||||
r.id,
|
||||
resolvedWorkspaceId!,
|
||||
authenticatedUserId,
|
||||
actualChatId
|
||||
)
|
||||
if (!ctx) return null
|
||||
return {
|
||||
...ctx,
|
||||
tag: r.active ? '@active_tab' : '@open_tab',
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
agentContexts.push(result.value)
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error(
|
||||
`[${tracker.requestId}] Failed to resolve resource attachment`,
|
||||
result.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
|
||||
const userPermission = resolvedWorkspaceId
|
||||
@@ -291,6 +340,21 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
} catch {}
|
||||
|
||||
if (stream && actualChatId) {
|
||||
const acquired = await acquirePendingChatStream(actualChatId, userMessageIdToUse)
|
||||
if (!acquired) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
pendingChatStreamAcquired = true
|
||||
pendingChatStreamID = userMessageIdToUse
|
||||
}
|
||||
|
||||
if (actualChatId) {
|
||||
const userMsg = {
|
||||
id: userMessageIdToUse,
|
||||
@@ -337,6 +401,7 @@ export async function POST(req: NextRequest) {
|
||||
titleProvider: provider,
|
||||
requestId: tracker.requestId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
pendingChatStreamAlreadyRegistered: Boolean(actualChatId && stream),
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
@@ -348,6 +413,7 @@ export async function POST(req: NextRequest) {
|
||||
interactive: true,
|
||||
onComplete: async (result: OrchestratorResult) => {
|
||||
if (!actualChatId) return
|
||||
if (!result.success) return
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -423,6 +489,7 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
},
|
||||
})
|
||||
pendingChatStreamHandedOff = true
|
||||
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
@@ -528,6 +595,14 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
actualChatId &&
|
||||
pendingChatStreamAcquired &&
|
||||
!pendingChatStreamHandedOff &&
|
||||
pendingChatStreamID
|
||||
) {
|
||||
await releasePendingChatStream(actualChatId, pendingChatStreamID).catch(() => {})
|
||||
}
|
||||
const duration = tracker.getDuration()
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import { getSession } from '@/lib/auth'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import {
|
||||
acquirePendingChatStream,
|
||||
createSSEStream,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
waitForPendingChatStream,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
|
||||
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
|
||||
@@ -253,7 +253,16 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
|
||||
if (actualChatId) {
|
||||
await waitForPendingChatStream(actualChatId)
|
||||
const acquired = await acquirePendingChatStream(actualChatId, userMessageId)
|
||||
if (!acquired) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
@@ -271,6 +280,7 @@ export async function POST(req: NextRequest) {
|
||||
titleModel: 'claude-opus-4-6',
|
||||
requestId: tracker.requestId,
|
||||
workspaceId,
|
||||
pendingChatStreamAlreadyRegistered: Boolean(actualChatId),
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workspaceId,
|
||||
@@ -282,6 +292,7 @@ export async function POST(req: NextRequest) {
|
||||
interactive: true,
|
||||
onComplete: async (result: OrchestratorResult) => {
|
||||
if (!actualChatId) return
|
||||
if (!result.success) return
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
id: crypto.randomUUID(),
|
||||
|
||||
@@ -13,6 +13,7 @@ const MetadataSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
metadata: z.object({
|
||||
columnWidths: z.record(z.number().positive()).optional(),
|
||||
columnOrder: z.array(z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -20,11 +20,15 @@
|
||||
* - durationInMonths: number (required when duration is 'repeating')
|
||||
* - maxRedemptions: number (optional) — Total redemption cap
|
||||
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
|
||||
* - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
|
||||
* Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { getPlans } from '@/lib/billing/plans'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
@@ -38,6 +42,17 @@ const logger = createLogger('AdminPromoCodes')
|
||||
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
|
||||
type Duration = (typeof VALID_DURATIONS)[number]
|
||||
|
||||
/** Broad categories match all tiers; specific plan names match exactly. */
|
||||
const VALID_APPLIES_TO = [
|
||||
'pro',
|
||||
'team',
|
||||
'pro_6000',
|
||||
'pro_25000',
|
||||
'team_6000',
|
||||
'team_25000',
|
||||
] as const
|
||||
type AppliesTo = (typeof VALID_APPLIES_TO)[number]
|
||||
|
||||
interface PromoCodeResponse {
|
||||
id: string
|
||||
code: string
|
||||
@@ -46,6 +61,7 @@ interface PromoCodeResponse {
|
||||
percentOff: number
|
||||
duration: string
|
||||
durationInMonths: number | null
|
||||
appliesToProductIds: string[] | null
|
||||
maxRedemptions: number | null
|
||||
expiresAt: string | null
|
||||
active: boolean
|
||||
@@ -62,6 +78,7 @@ function formatPromoCode(promo: {
|
||||
percent_off: number | null
|
||||
duration: string
|
||||
duration_in_months: number | null
|
||||
applies_to?: { products: string[] }
|
||||
}
|
||||
max_redemptions: number | null
|
||||
expires_at: number | null
|
||||
@@ -77,6 +94,7 @@ function formatPromoCode(promo: {
|
||||
percentOff: promo.coupon.percent_off ?? 0,
|
||||
duration: promo.coupon.duration,
|
||||
durationInMonths: promo.coupon.duration_in_months,
|
||||
appliesToProductIds: promo.coupon.applies_to?.products ?? null,
|
||||
maxRedemptions: promo.max_redemptions,
|
||||
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
|
||||
active: promo.active,
|
||||
@@ -85,6 +103,54 @@ function formatPromoCode(promo: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve appliesTo values to unique Stripe product IDs.
|
||||
* Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
|
||||
* Specific plan names ('pro_6000', 'team_25000') match exactly.
|
||||
*/
|
||||
async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise<string[]> {
|
||||
const plans = getPlans()
|
||||
const priceIds: string[] = []
|
||||
|
||||
const broadMatchers: Record<string, (name: string) => boolean> = {
|
||||
pro: isPro,
|
||||
team: isTeam,
|
||||
}
|
||||
|
||||
for (const plan of plans) {
|
||||
const matches = targets.some((target) => {
|
||||
const matcher = broadMatchers[target]
|
||||
return matcher ? matcher(plan.name) : plan.name === target
|
||||
})
|
||||
if (!matches) continue
|
||||
if (plan.priceId) priceIds.push(plan.priceId)
|
||||
if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId)
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
priceIds.map(async (priceId) => {
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
return typeof price.product === 'string' ? price.product : price.product.id
|
||||
})
|
||||
)
|
||||
|
||||
const failures = results.filter((r) => r.status === 'rejected')
|
||||
if (failures.length > 0) {
|
||||
logger.error('Failed to resolve all Stripe products for appliesTo', {
|
||||
failed: failures.length,
|
||||
total: priceIds.length,
|
||||
})
|
||||
throw new Error('Could not resolve all Stripe products for the specified plan categories.')
|
||||
}
|
||||
|
||||
const productIds = new Set<string>()
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') productIds.add(r.value)
|
||||
}
|
||||
|
||||
return [...productIds]
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
@@ -125,7 +191,16 @@ export const POST = withAdminAuth(async (request) => {
|
||||
const stripe = requireStripeClient()
|
||||
const body = await request.json()
|
||||
|
||||
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
|
||||
const {
|
||||
name,
|
||||
percentOff,
|
||||
code,
|
||||
duration,
|
||||
durationInMonths,
|
||||
maxRedemptions,
|
||||
expiresAt,
|
||||
appliesTo,
|
||||
} = body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return badRequestResponse('name is required and must be a non-empty string')
|
||||
@@ -186,11 +261,36 @@ export const POST = withAdminAuth(async (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (appliesTo !== undefined && appliesTo !== null) {
|
||||
if (!Array.isArray(appliesTo) || appliesTo.length === 0) {
|
||||
return badRequestResponse('appliesTo must be a non-empty array')
|
||||
}
|
||||
const invalid = appliesTo.filter(
|
||||
(v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo)
|
||||
)
|
||||
if (invalid.length > 0) {
|
||||
return badRequestResponse(
|
||||
`appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let appliesToProducts: string[] | undefined
|
||||
if (appliesTo?.length) {
|
||||
appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[])
|
||||
if (appliesToProducts.length === 0) {
|
||||
return badRequestResponse(
|
||||
'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const coupon = await stripe.coupons.create({
|
||||
name: name.trim(),
|
||||
percent_off: percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
|
||||
...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}),
|
||||
})
|
||||
|
||||
let promoCode
|
||||
@@ -224,6 +324,7 @@ export const POST = withAdminAuth(async (request) => {
|
||||
couponId: coupon.id,
|
||||
percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(appliesTo ? { appliesTo } : {}),
|
||||
})
|
||||
|
||||
return singleResponse(formatPromoCode(promoCode))
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles'
|
||||
|
||||
const ParticlesVisualization = dynamic(
|
||||
() =>
|
||||
import('@/app/chat/components/voice-interface/components/particles').then(
|
||||
(mod) => mod.ParticlesVisualization
|
||||
),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const logger = createLogger('VoiceInterface')
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -281,9 +282,14 @@ function TemplateCardInner({
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
<Image
|
||||
src={authorImageUrl}
|
||||
alt={author}
|
||||
width={20}
|
||||
height={20}
|
||||
className='flex-shrink-0 rounded-full object-cover'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-7)]'>
|
||||
<User className='h-[12px] w-[12px] text-[var(--text-muted)]' />
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NavTour, START_NAV_TOUR_EVENT } from './product-tour'
|
||||
export { START_WORKFLOW_TOUR_EVENT, WorkflowTour } from './workflow-tour'
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Step } from 'react-joyride'
|
||||
|
||||
export const navTourSteps: Step[] = [
|
||||
{
|
||||
target: '[data-tour="nav-home"]',
|
||||
title: 'Home',
|
||||
content:
|
||||
'Your starting point. Describe what you want to build in plain language or pick a template to get started.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-search"]',
|
||||
title: 'Search',
|
||||
content: 'Quickly find workflows, blocks, and tools. Use Cmd+K to open it from anywhere.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-tables"]',
|
||||
title: 'Tables',
|
||||
content:
|
||||
'Store and query structured data. Your workflows can read and write to tables directly.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-files"]',
|
||||
title: 'Files',
|
||||
content: 'Upload and manage files that your workflows can process, transform, or reference.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-knowledge-base"]',
|
||||
title: 'Knowledge Base',
|
||||
content:
|
||||
'Build knowledge bases from your documents. Set up connectors to give your agents realtime access to your data sources from sources like Notion, Drive, Slack, Confluence, and more.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-scheduled-tasks"]',
|
||||
title: 'Scheduled Tasks',
|
||||
content:
|
||||
'View and manage background tasks. Set up new tasks, or view the tasks the Mothership is monitoring for upcoming or past executions.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-logs"]',
|
||||
title: 'Logs',
|
||||
content:
|
||||
'Monitor every workflow execution. See inputs, outputs, errors, and timing for each run. View analytics on performance and costs, filter previous runs, and view snapshots of the workflow at the time of execution.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-tasks"]',
|
||||
title: 'Tasks',
|
||||
content:
|
||||
'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-workflows"]',
|
||||
title: 'Workflows',
|
||||
content:
|
||||
'All your workflows live here. Create new ones with the + button and organize them into folders. Deploy your workflows as API, webhook, schedule, or chat widget. Then hit Run to test it out.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps'
|
||||
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import {
|
||||
getSharedJoyrideProps,
|
||||
TourStateContext,
|
||||
TourTooltipAdapter,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
|
||||
|
||||
const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
|
||||
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
|
||||
|
||||
export function NavTour() {
|
||||
const pathname = usePathname()
|
||||
const isWorkflowPage = /\/w\/[^/]+/.test(pathname)
|
||||
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: navTourSteps,
|
||||
storageKey: NAV_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 1200,
|
||||
resettable: true,
|
||||
triggerEvent: START_NAV_TOUR_EVENT,
|
||||
tourName: 'Navigation tour',
|
||||
disabled: isWorkflowPage,
|
||||
})
|
||||
|
||||
const tourState = useMemo<TourState>(
|
||||
() => ({
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
totalSteps: navTourSteps.length,
|
||||
}),
|
||||
[isTooltipVisible, isEntrance]
|
||||
)
|
||||
|
||||
return (
|
||||
<TourStateContext.Provider value={tourState}>
|
||||
<Joyride
|
||||
key={tourKey}
|
||||
steps={navTourSteps}
|
||||
run={run}
|
||||
stepIndex={stepIndex}
|
||||
callback={handleCallback}
|
||||
continuous
|
||||
disableScrolling
|
||||
disableScrollParentFix
|
||||
disableOverlayClose
|
||||
spotlightPadding={4}
|
||||
tooltipComponent={TourTooltipAdapter}
|
||||
{...getSharedJoyrideProps({ spotlightBorderRadius: 8 })}
|
||||
/>
|
||||
</TourStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import type { TooltipRenderProps } from 'react-joyride'
|
||||
import { TourTooltip } from '@/components/emcn'
|
||||
|
||||
/** Shared state passed from the tour component to the tooltip adapter via context */
|
||||
export interface TourState {
|
||||
isTooltipVisible: boolean
|
||||
isEntrance: boolean
|
||||
totalSteps: number
|
||||
}
|
||||
|
||||
export const TourStateContext = createContext<TourState>({
|
||||
isTooltipVisible: true,
|
||||
isEntrance: true,
|
||||
totalSteps: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Maps Joyride placement strings to TourTooltip placement values.
|
||||
*/
|
||||
function mapPlacement(placement?: string): 'top' | 'right' | 'bottom' | 'left' | 'center' {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
case 'top-start':
|
||||
case 'top-end':
|
||||
return 'top'
|
||||
case 'right':
|
||||
case 'right-start':
|
||||
case 'right-end':
|
||||
return 'right'
|
||||
case 'bottom':
|
||||
case 'bottom-start':
|
||||
case 'bottom-end':
|
||||
return 'bottom'
|
||||
case 'left':
|
||||
case 'left-start':
|
||||
case 'left-end':
|
||||
return 'left'
|
||||
case 'center':
|
||||
return 'center'
|
||||
default:
|
||||
return 'bottom'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that bridges Joyride's tooltip render props to the EMCN TourTooltip component.
|
||||
* Reads transition state from TourStateContext to coordinate fade animations.
|
||||
*/
|
||||
export function TourTooltipAdapter({
|
||||
step,
|
||||
index,
|
||||
isLastStep,
|
||||
tooltipProps,
|
||||
primaryProps,
|
||||
backProps,
|
||||
closeProps,
|
||||
}: TooltipRenderProps) {
|
||||
const { isTooltipVisible, isEntrance, totalSteps } = useContext(TourStateContext)
|
||||
const [targetEl, setTargetEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const { target } = step
|
||||
if (typeof target === 'string') {
|
||||
setTargetEl(document.querySelector<HTMLElement>(target))
|
||||
} else if (target instanceof HTMLElement) {
|
||||
setTargetEl(target)
|
||||
} else {
|
||||
setTargetEl(null)
|
||||
}
|
||||
}, [step])
|
||||
|
||||
/**
|
||||
* Forwards the Joyride tooltip ref safely, handling both
|
||||
* callback refs and RefObject refs from the library.
|
||||
* Memoized to prevent ref churn (null → node cycling) on re-renders.
|
||||
*/
|
||||
const setJoyrideRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
const { ref } = tooltipProps
|
||||
if (!ref) return
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
} else {
|
||||
;(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tooltipProps.ref]
|
||||
)
|
||||
|
||||
const placement = mapPlacement(step.placement)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setJoyrideRef}
|
||||
role={tooltipProps.role}
|
||||
aria-modal={tooltipProps['aria-modal']}
|
||||
style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 0, height: 0 }}
|
||||
/>
|
||||
<TourTooltip
|
||||
title={step.title as string}
|
||||
description={step.content}
|
||||
step={index + 1}
|
||||
totalSteps={totalSteps}
|
||||
placement={placement}
|
||||
targetEl={targetEl}
|
||||
isFirst={index === 0}
|
||||
isLast={isLastStep}
|
||||
isVisible={isTooltipVisible}
|
||||
isEntrance={isEntrance && index === 0}
|
||||
onNext={primaryProps.onClick as () => void}
|
||||
onBack={backProps.onClick as () => void}
|
||||
onClose={closeProps.onClick as () => void}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SPOTLIGHT_TRANSITION =
|
||||
'top 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), left 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), width 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
|
||||
/**
|
||||
* Returns the shared Joyride floaterProps and styles config used by both tours.
|
||||
* Only `spotlightPadding` and spotlight `borderRadius` differ between tours.
|
||||
*/
|
||||
export function getSharedJoyrideProps(overrides: { spotlightBorderRadius: number }) {
|
||||
return {
|
||||
floaterProps: {
|
||||
disableAnimation: true,
|
||||
hideArrow: true,
|
||||
styles: {
|
||||
floater: {
|
||||
filter: 'none',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
options: {
|
||||
zIndex: 10000,
|
||||
},
|
||||
spotlight: {
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: overrides.spotlightBorderRadius,
|
||||
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.55)',
|
||||
position: 'fixed' as React.CSSProperties['position'],
|
||||
transition: SPOTLIGHT_TRANSITION,
|
||||
},
|
||||
overlay: {
|
||||
backgroundColor: 'transparent',
|
||||
mixBlendMode: 'unset' as React.CSSProperties['mixBlendMode'],
|
||||
position: 'fixed' as React.CSSProperties['position'],
|
||||
height: '100%',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride'
|
||||
|
||||
const logger = createLogger('useTour')
|
||||
|
||||
/** Transition delay before updating step index (ms) */
|
||||
const FADE_OUT_MS = 80
|
||||
|
||||
interface UseTourOptions {
|
||||
/** Tour step definitions */
|
||||
steps: Step[]
|
||||
/** localStorage key for completion persistence */
|
||||
storageKey: string
|
||||
/** Delay before auto-starting the tour (ms) */
|
||||
autoStartDelay?: number
|
||||
/** Whether this tour can be reset/retriggered */
|
||||
resettable?: boolean
|
||||
/** Custom event name to listen for manual triggers */
|
||||
triggerEvent?: string
|
||||
/** Identifier for logging */
|
||||
tourName?: string
|
||||
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface UseTourReturn {
|
||||
/** Whether the tour is currently running */
|
||||
run: boolean
|
||||
/** Current step index */
|
||||
stepIndex: number
|
||||
/** Key to force Joyride remount on retrigger */
|
||||
tourKey: number
|
||||
/** Whether the tooltip is visible (false during step transitions) */
|
||||
isTooltipVisible: boolean
|
||||
/** Whether this is the initial entrance animation */
|
||||
isEntrance: boolean
|
||||
/** Joyride callback handler */
|
||||
handleCallback: (data: CallBackProps) => void
|
||||
}
|
||||
|
||||
function isTourCompleted(storageKey: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markTourCompleted(storageKey: string): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, 'true')
|
||||
} catch {
|
||||
logger.warn('Failed to persist tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
function clearTourCompletion(storageKey: string): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
logger.warn('Failed to clear tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which tours have already attempted auto-start in this page session.
|
||||
* Module-level so it survives component remounts (e.g. navigating between
|
||||
* workflows remounts WorkflowTour), while still resetting on full page reload.
|
||||
*/
|
||||
const autoStartAttempted = new Set<string>()
|
||||
|
||||
/**
|
||||
* Shared hook for managing product tour state with smooth transitions.
|
||||
*
|
||||
* Handles auto-start on first visit, localStorage persistence,
|
||||
* manual triggering via custom events, and coordinated fade
|
||||
* transitions between steps to prevent layout shift.
|
||||
*/
|
||||
export function useTour({
|
||||
steps,
|
||||
storageKey,
|
||||
autoStartDelay = 1200,
|
||||
resettable = false,
|
||||
triggerEvent,
|
||||
tourName = 'tour',
|
||||
disabled = false,
|
||||
}: UseTourOptions): UseTourReturn {
|
||||
const [run, setRun] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [tourKey, setTourKey] = useState(0)
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
|
||||
const [isEntrance, setIsEntrance] = useState(true)
|
||||
|
||||
const disabledRef = useRef(disabled)
|
||||
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
}, [disabled])
|
||||
|
||||
/**
|
||||
* Schedules a two-frame rAF to reveal the tooltip after the browser
|
||||
* finishes repositioning. Stores the outer frame ID in `rafRef` so
|
||||
* it can be cancelled on unmount or when the tour is interrupted.
|
||||
*/
|
||||
const scheduleReveal = useCallback(() => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** Cancels any pending transition timer and rAF reveal */
|
||||
const cancelPendingTransitions = useCallback(() => {
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current)
|
||||
transitionTimerRef.current = null
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
markTourCompleted(storageKey)
|
||||
}, [storageKey, cancelPendingTransitions])
|
||||
|
||||
/** Transition to a new step with a coordinated fade-out/fade-in */
|
||||
const transitionToStep = useCallback(
|
||||
(newIndex: number) => {
|
||||
if (newIndex < 0 || newIndex >= steps.length) {
|
||||
stopTour()
|
||||
return
|
||||
}
|
||||
|
||||
setIsTooltipVisible(false)
|
||||
cancelPendingTransitions()
|
||||
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
transitionTimerRef.current = null
|
||||
setStepIndex(newIndex)
|
||||
setIsEntrance(false)
|
||||
scheduleReveal()
|
||||
}, FADE_OUT_MS)
|
||||
},
|
||||
[steps.length, stopTour, cancelPendingTransitions, scheduleReveal]
|
||||
)
|
||||
|
||||
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
|
||||
useEffect(() => {
|
||||
if (disabled && run) {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
logger.info(`${tourName} paused — disabled became true`)
|
||||
}
|
||||
}, [disabled, run, tourName, cancelPendingTransitions])
|
||||
|
||||
/** Auto-start on first visit (once per page session per tour) */
|
||||
useEffect(() => {
|
||||
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (disabledRef.current) return
|
||||
|
||||
autoStartAttempted.add(storageKey)
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`Auto-starting ${tourName}`)
|
||||
scheduleReveal()
|
||||
}, autoStartDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
|
||||
|
||||
/** Listen for manual trigger events */
|
||||
useEffect(() => {
|
||||
if (!triggerEvent || !resettable) return
|
||||
|
||||
const handleTrigger = () => {
|
||||
setRun(false)
|
||||
clearTourCompletion(storageKey)
|
||||
setTourKey((k) => k + 1)
|
||||
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
|
||||
retriggerTimerRef.current = setTimeout(() => {
|
||||
retriggerTimerRef.current = null
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`${tourName} triggered via event`)
|
||||
scheduleReveal()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
window.addEventListener(triggerEvent, handleTrigger)
|
||||
return () => {
|
||||
window.removeEventListener(triggerEvent, handleTrigger)
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
|
||||
|
||||
/** Clean up all pending async work on unmount */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelPendingTransitions()
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [cancelPendingTransitions])
|
||||
|
||||
const handleCallback = useCallback(
|
||||
(data: CallBackProps) => {
|
||||
const { action, index, status, type } = data
|
||||
|
||||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} ended`, { status })
|
||||
return
|
||||
}
|
||||
|
||||
if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
|
||||
if (action === ACTIONS.CLOSE) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} closed by user`)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = index + (action === ACTIONS.PREV ? -1 : 1)
|
||||
|
||||
if (type === EVENTS.TARGET_NOT_FOUND) {
|
||||
logger.info(`${tourName} step target not found, skipping`, {
|
||||
stepIndex: index,
|
||||
target: steps[index]?.target,
|
||||
})
|
||||
}
|
||||
|
||||
transitionToStep(nextIndex)
|
||||
}
|
||||
},
|
||||
[stopTour, transitionToStep, steps, tourName]
|
||||
)
|
||||
|
||||
return {
|
||||
run,
|
||||
stepIndex,
|
||||
tourKey,
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
handleCallback,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Step } from 'react-joyride'
|
||||
|
||||
export const workflowTourSteps: Step[] = [
|
||||
{
|
||||
target: '[data-tour="canvas"]',
|
||||
title: 'The Canvas',
|
||||
content:
|
||||
'This is where you build visually. Drag blocks onto the canvas and connect them to create AI workflows.',
|
||||
placement: 'center',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="tab-copilot"]',
|
||||
title: 'AI Copilot',
|
||||
content:
|
||||
'Build and debug workflows using natural language. Describe what you want and Copilot creates the blocks for you.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="tab-toolbar"]',
|
||||
title: 'Block Library',
|
||||
content:
|
||||
'Browse all available blocks and triggers. Drag them onto the canvas to build your workflow step by step.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="tab-editor"]',
|
||||
title: 'Block Editor',
|
||||
content:
|
||||
'Click any block on the canvas to configure it here. Set inputs, credentials, and fine-tune behavior.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="deploy-run"]',
|
||||
title: 'Deploy & Run',
|
||||
content:
|
||||
'Deploy your workflow as an API, webhook, schedule, or chat widget. Then hit Run to test it out.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="workflow-controls"]',
|
||||
title: 'Canvas Controls',
|
||||
content:
|
||||
'Switch between pointer and hand mode, undo or redo changes, and fit the canvas to your view.',
|
||||
placement: 'top',
|
||||
spotlightPadding: 0,
|
||||
disableBeacon: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import {
|
||||
getSharedJoyrideProps,
|
||||
TourStateContext,
|
||||
TourTooltipAdapter,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
|
||||
import { workflowTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps'
|
||||
|
||||
const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
|
||||
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
|
||||
|
||||
/**
|
||||
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
|
||||
* Runs on first workflow visit and can be retriggered via "Take a tour".
|
||||
*/
|
||||
export function WorkflowTour() {
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: workflowTourSteps,
|
||||
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 800,
|
||||
resettable: true,
|
||||
triggerEvent: START_WORKFLOW_TOUR_EVENT,
|
||||
tourName: 'Workflow tour',
|
||||
})
|
||||
|
||||
const tourState = useMemo<TourState>(
|
||||
() => ({
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
totalSteps: workflowTourSteps.length,
|
||||
}),
|
||||
[isTooltipVisible, isEntrance]
|
||||
)
|
||||
|
||||
return (
|
||||
<TourStateContext.Provider value={tourState}>
|
||||
<Joyride
|
||||
key={tourKey}
|
||||
steps={workflowTourSteps}
|
||||
run={run}
|
||||
stepIndex={stepIndex}
|
||||
callback={handleCallback}
|
||||
continuous
|
||||
disableScrolling
|
||||
disableScrollParentFix
|
||||
disableOverlayClose
|
||||
spotlightPadding={1}
|
||||
tooltipComponent={TourTooltipAdapter}
|
||||
{...getSharedJoyrideProps({ spotlightBorderRadius: 6 })}
|
||||
/>
|
||||
</TourStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
} from './message-content'
|
||||
export { MothershipChat } from './mothership-chat/mothership-chat'
|
||||
export { MothershipView } from './mothership-view'
|
||||
export { QueuedMessages } from './queued-messages'
|
||||
export { TemplatePrompts } from './template-prompts'
|
||||
|
||||
@@ -44,6 +44,21 @@ export function AgentGroup({
|
||||
const [expanded, setExpanded] = useState(defaultExpanded || !allDone)
|
||||
const [mounted, setMounted] = useState(defaultExpanded || !allDone)
|
||||
const didAutoCollapseRef = useRef(allDone)
|
||||
const wasAutoExpandedRef = useRef(defaultExpanded)
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultExpanded) {
|
||||
wasAutoExpandedRef.current = true
|
||||
setMounted(true)
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (wasAutoExpandedRef.current && allDone) {
|
||||
wasAutoExpandedRef.current = false
|
||||
setExpanded(false)
|
||||
}
|
||||
}, [defaultExpanded, allDone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoCollapse || didAutoCollapseRef.current) return
|
||||
@@ -65,7 +80,10 @@ export function AgentGroup({
|
||||
{hasItems ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
onClick={() => {
|
||||
wasAutoExpandedRef.current = false
|
||||
setExpanded((prev) => !prev)
|
||||
}}
|
||||
className='flex cursor-pointer items-center gap-[8px]'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
|
||||
@@ -389,7 +389,7 @@ export function MessageContent({
|
||||
return (
|
||||
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
|
||||
<AgentGroup
|
||||
key={`${segment.id}-${segment.id === lastOpenSubagentGroupId ? 'expanded' : 'default'}`}
|
||||
key={segment.id}
|
||||
agentName={segment.agentName}
|
||||
agentLabel={segment.agentLabel}
|
||||
items={segment.items}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/message-content'
|
||||
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/home/components/user-input'
|
||||
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
|
||||
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import type {
|
||||
ChatMessage,
|
||||
FileAttachmentForApi,
|
||||
QueuedMessage,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
interface MothershipChatProps {
|
||||
messages: ChatMessage[]
|
||||
isSending: boolean
|
||||
onSubmit: (
|
||||
text: string,
|
||||
fileAttachments?: FileAttachmentForApi[],
|
||||
contexts?: ChatContext[]
|
||||
) => void
|
||||
onStopGeneration: () => void
|
||||
messageQueue: QueuedMessage[]
|
||||
onRemoveQueuedMessage: (id: string) => void
|
||||
onSendQueuedMessage: (id: string) => Promise<void>
|
||||
onEditQueuedMessage: (id: string) => void
|
||||
userId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
layout?: 'mothership-view' | 'copilot-view'
|
||||
initialScrollBlocked?: boolean
|
||||
animateInput?: boolean
|
||||
onInputAnimationEnd?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LAYOUT_STYLES = {
|
||||
'mothership-view': {
|
||||
scrollContainer:
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
|
||||
content: 'mx-auto max-w-[42rem] space-y-6',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
attachmentWidth: 'max-w-[70%]',
|
||||
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
|
||||
assistantRow: 'group/msg relative pb-5',
|
||||
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
|
||||
footerInner: 'mx-auto max-w-[42rem]',
|
||||
},
|
||||
'copilot-view': {
|
||||
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
|
||||
content: 'space-y-4',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-2',
|
||||
attachmentWidth: 'max-w-[85%]',
|
||||
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
|
||||
assistantRow: 'group/msg relative pb-3',
|
||||
footer: 'flex-shrink-0 px-3 pb-3',
|
||||
footerInner: '',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function MothershipChat({
|
||||
messages,
|
||||
isSending,
|
||||
onSubmit,
|
||||
onStopGeneration,
|
||||
messageQueue,
|
||||
onRemoveQueuedMessage,
|
||||
onSendQueuedMessage,
|
||||
onEditQueuedMessage,
|
||||
userId,
|
||||
onContextAdd,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
layout = 'mothership-view',
|
||||
initialScrollBlocked = false,
|
||||
animateInput = false,
|
||||
onInputAnimationEnd,
|
||||
className,
|
||||
}: MothershipChatProps) {
|
||||
const styles = LAYOUT_STYLES[layout]
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
initialScrollDoneRef.current = false
|
||||
return
|
||||
}
|
||||
if (initialScrollDoneRef.current || initialScrollBlocked) return
|
||||
initialScrollDoneRef.current = true
|
||||
scrollToBottom()
|
||||
}, [hasMessages, initialScrollBlocked, scrollToBottom])
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full min-h-0 flex-col', className)}>
|
||||
<div ref={scrollContainerRef} className={styles.scrollContainer}>
|
||||
<div className={styles.content}>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
const hasAttachments = Boolean(msg.attachments?.length)
|
||||
return (
|
||||
<div key={msg.id} className={styles.userRow}>
|
||||
{hasAttachments && (
|
||||
<ChatMessageAttachments
|
||||
attachments={msg.attachments ?? []}
|
||||
align='end'
|
||||
className={styles.attachmentWidth}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.userBubble}>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant = index === messages.length - 1
|
||||
const isThisStreaming = isSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={styles.assistantRow}>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? onSubmit : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
|
||||
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
|
||||
>
|
||||
<div className={styles.footerInner}>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={onRemoveQueuedMessage}
|
||||
onSendNow={onSendQueuedMessage}
|
||||
onEdit={onEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={onSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={onStopGeneration}
|
||||
isInitialView={false}
|
||||
userId={userId}
|
||||
onContextAdd={onContextAdd}
|
||||
editValue={editValue}
|
||||
onEditValueConsumed={onEditValueConsumed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,6 +106,7 @@ const SEND_BUTTON_ACTIVE =
|
||||
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||
|
||||
const MAX_CHAT_TEXTAREA_HEIGHT = 200
|
||||
const SPEECH_RECOGNITION_LANG = 'en-US'
|
||||
|
||||
const DROP_OVERLAY_ICONS = [
|
||||
PdfIcon,
|
||||
@@ -267,6 +268,7 @@ export function UserInput({
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null)
|
||||
const prefixRef = useRef('')
|
||||
const valueRef = useRef(value)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -274,6 +276,10 @@ export function UserInput({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value
|
||||
}, [value])
|
||||
|
||||
const textareaRef = mentionMenu.textareaRef
|
||||
const wasSendingRef = useRef(false)
|
||||
const atInsertPosRef = useRef<number | null>(null)
|
||||
@@ -390,6 +396,84 @@ export function UserInput({
|
||||
[textareaRef]
|
||||
)
|
||||
|
||||
const startRecognition = useCallback((): boolean => {
|
||||
const w = window as WindowWithSpeech
|
||||
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
|
||||
if (!SpeechRecognitionAPI) return false
|
||||
|
||||
const recognition = new SpeechRecognitionAPI()
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
recognition.lang = SPEECH_RECOGNITION_LANG
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let transcript = ''
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript
|
||||
}
|
||||
const prefix = prefixRef.current
|
||||
const newVal = prefix ? `${prefix} ${transcript}` : transcript
|
||||
setValue(newVal)
|
||||
valueRef.current = newVal
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (recognitionRef.current === recognition) {
|
||||
prefixRef.current = valueRef.current
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
if (e.error === 'aborted' || e.error === 'not-allowed') {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
|
||||
recognitionRef.current = recognition
|
||||
try {
|
||||
recognition.start()
|
||||
return true
|
||||
} catch {
|
||||
recognitionRef.current = null
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const restartRecognition = useCallback(
|
||||
(newPrefix: string) => {
|
||||
if (!recognitionRef.current) return
|
||||
prefixRef.current = newPrefix
|
||||
recognitionRef.current.abort()
|
||||
recognitionRef.current = null
|
||||
if (!startRecognition()) {
|
||||
setIsListening(false)
|
||||
}
|
||||
},
|
||||
[startRecognition]
|
||||
)
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop()
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
return
|
||||
}
|
||||
|
||||
prefixRef.current = value
|
||||
if (startRecognition()) {
|
||||
setIsListening(true)
|
||||
}
|
||||
}, [isListening, value, startRecognition])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles
|
||||
.filter((f) => !f.uploading && f.key)
|
||||
@@ -407,13 +491,14 @@ export function UserInput({
|
||||
contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined
|
||||
)
|
||||
setValue('')
|
||||
restartRecognition('')
|
||||
files.clearAttachedFiles()
|
||||
contextManagement.clearContexts()
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}, [onSubmit, files, value, contextManagement, textareaRef])
|
||||
}, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -488,27 +573,33 @@ export function UserInput({
|
||||
[handleSubmit, mentionTokensWithContext, value, textareaRef]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
|
||||
if (
|
||||
caret > 0 &&
|
||||
newValue.charAt(caret - 1) === '@' &&
|
||||
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
|
||||
) {
|
||||
const before = newValue.slice(0, caret - 1)
|
||||
const after = newValue.slice(caret)
|
||||
setValue(`${before}${after}`)
|
||||
atInsertPosRef.current = caret - 1
|
||||
setPlusMenuOpen(true)
|
||||
setPlusMenuSearch('')
|
||||
setPlusMenuActiveIndex(0)
|
||||
return
|
||||
}
|
||||
if (
|
||||
caret > 0 &&
|
||||
newValue.charAt(caret - 1) === '@' &&
|
||||
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
|
||||
) {
|
||||
const before = newValue.slice(0, caret - 1)
|
||||
const after = newValue.slice(caret)
|
||||
const adjusted = `${before}${after}`
|
||||
setValue(adjusted)
|
||||
atInsertPosRef.current = caret - 1
|
||||
setPlusMenuOpen(true)
|
||||
setPlusMenuSearch('')
|
||||
setPlusMenuActiveIndex(0)
|
||||
restartRecognition(adjusted)
|
||||
return
|
||||
}
|
||||
|
||||
setValue(newValue)
|
||||
}, [])
|
||||
setValue(newValue)
|
||||
restartRecognition(newValue)
|
||||
},
|
||||
[restartRecognition]
|
||||
)
|
||||
|
||||
const handleSelectAdjust = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
@@ -536,56 +627,6 @@ export function UserInput({
|
||||
[isInitialView]
|
||||
)
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop()
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
return
|
||||
}
|
||||
|
||||
const w = window as WindowWithSpeech
|
||||
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
|
||||
if (!SpeechRecognitionAPI) return
|
||||
|
||||
prefixRef.current = value
|
||||
|
||||
const recognition = new SpeechRecognitionAPI()
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
recognition.lang = 'en-US'
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let transcript = ''
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript
|
||||
}
|
||||
const prefix = prefixRef.current
|
||||
setValue(prefix ? `${prefix} ${transcript}` : transcript)
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (recognitionRef.current === recognition) {
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
|
||||
if (e.error === 'aborted' || e.error === 'not-allowed') {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
|
||||
recognitionRef.current = recognition
|
||||
recognition.start()
|
||||
setIsListening(true)
|
||||
}, [isListening, value])
|
||||
|
||||
const renderOverlayContent = useCallback(() => {
|
||||
const contexts = contextManagement.selectedContexts
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { PanelLeft } from '@/components/emcn/icons'
|
||||
@@ -11,21 +11,10 @@ import {
|
||||
LandingWorkflowSeedStorage,
|
||||
} from '@/lib/core/utils/browser-storage'
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
ChatMessageAttachments,
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
QueuedMessages,
|
||||
TemplatePrompts,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from './components'
|
||||
import { PendingTagIndicator } from './components/message-content/components/special-tags'
|
||||
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
|
||||
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
|
||||
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
|
||||
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
@@ -173,7 +162,11 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
streamingFile,
|
||||
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
chatId,
|
||||
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
|
||||
)
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [prevChatId, setPrevChatId] = useState(chatId)
|
||||
@@ -285,22 +278,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
[addResource, handleResourceEvent]
|
||||
)
|
||||
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
initialScrollDoneRef.current = false
|
||||
return
|
||||
}
|
||||
if (initialScrollDoneRef.current) return
|
||||
if (resources.length > 0 && isResourceCollapsed) return
|
||||
|
||||
initialScrollDoneRef.current = true
|
||||
scrollToBottom()
|
||||
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMessages) return
|
||||
@@ -322,11 +300,14 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return (
|
||||
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
|
||||
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<h1 className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
||||
<h1
|
||||
data-tour='home-greeting'
|
||||
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'
|
||||
>
|
||||
What should we get done
|
||||
{session?.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}?
|
||||
</h1>
|
||||
<div ref={initialViewInputRef} className='w-full'>
|
||||
<div ref={initialViewInputRef} className='w-full' data-tour='home-chat-input'>
|
||||
<UserInput
|
||||
defaultValue={initialPrompt}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -339,6 +320,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
</div>
|
||||
<div
|
||||
ref={templateRef}
|
||||
data-tour='home-templates'
|
||||
className='-mt-[30vh] mx-auto w-full max-w-[68rem] px-[16px] pb-[32px] sm:px-[24px] lg:px-[40px]'
|
||||
>
|
||||
<TemplatePrompts onSelect={handleSubmit} />
|
||||
@@ -350,90 +332,23 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return (
|
||||
<div className='relative flex h-full bg-[var(--bg)]'>
|
||||
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem] space-y-6'>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
const hasAttachments = msg.attachments && msg.attachments.length > 0
|
||||
return (
|
||||
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
|
||||
{hasAttachments && (
|
||||
<ChatMessageAttachments
|
||||
attachments={msg.attachments!}
|
||||
align='end'
|
||||
className='max-w-[70%]'
|
||||
/>
|
||||
)}
|
||||
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
|
||||
const isThisStreaming = isSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='group/msg relative pb-5'>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? sendMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 px-[24px] pb-[16px]${isInputEntering ? ' animate-slide-in-bottom' : ''}`}
|
||||
onAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={removeFromQueue}
|
||||
onSendNow={sendNow}
|
||||
onEdit={handleEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={stopGeneration}
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MothershipChat
|
||||
messages={messages}
|
||||
isSending={isSending}
|
||||
onSubmit={handleSubmit}
|
||||
onStopGeneration={stopGeneration}
|
||||
messageQueue={messageQueue}
|
||||
onRemoveQueuedMessage={removeFromQueue}
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={handleEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
animateInput={isInputEntering}
|
||||
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export { useAnimatedPlaceholder } from './use-animated-placeholder'
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export type { UseChatReturn } from './use-chat'
|
||||
export { useChat } from './use-chat'
|
||||
export {
|
||||
getMothershipUseChatOptions,
|
||||
getWorkflowCopilotUseChatOptions,
|
||||
useChat,
|
||||
} from './use-chat'
|
||||
export { useMothershipResize } from './use-mothership-resize'
|
||||
export { useStreamingReveal } from './use-streaming-reveal'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
markRunToolManuallyStopped,
|
||||
reportManualRunToolStop,
|
||||
} from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import { COPILOT_CHAT_API_PATH, MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import {
|
||||
extractResourcesFromToolResult,
|
||||
isResourceToolName,
|
||||
@@ -263,6 +263,29 @@ export interface UseChatOptions {
|
||||
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
export function getMothershipUseChatOptions(
|
||||
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: MOTHERSHIP_CHAT_API_PATH,
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowCopilotUseChatOptions(
|
||||
options: Pick<
|
||||
UseChatOptions,
|
||||
'workflowId' | 'onToolResult' | 'onTitleUpdate' | 'onStreamEnd'
|
||||
> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: COPILOT_CHAT_API_PATH,
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function useChat(
|
||||
workspaceId: string,
|
||||
initialChatId?: string,
|
||||
@@ -323,8 +346,8 @@ export function useChat(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
assistantId: string,
|
||||
expectedGen?: number
|
||||
) => Promise<void>
|
||||
>(async () => {})
|
||||
) => Promise<boolean>
|
||||
>(async () => false)
|
||||
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
@@ -415,6 +438,8 @@ export function useChat(
|
||||
setIsReconnecting(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setStreamingFile(null)
|
||||
streamingFileRef.current = null
|
||||
setMessageQueue([])
|
||||
}, [initialChatId, queryClient])
|
||||
|
||||
@@ -433,6 +458,8 @@ export function useChat(
|
||||
setIsReconnecting(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setStreamingFile(null)
|
||||
streamingFileRef.current = null
|
||||
setMessageQueue([])
|
||||
}, [isHomePage])
|
||||
|
||||
@@ -441,12 +468,6 @@ export function useChat(
|
||||
|
||||
const activeStreamId = chatHistory.activeStreamId
|
||||
const snapshot = chatHistory.streamSnapshot
|
||||
|
||||
if (activeStreamId && !snapshot && !sendingRef.current) {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatHistory.id) })
|
||||
return
|
||||
}
|
||||
|
||||
appliedChatIdRef.current = chatHistory.id
|
||||
const mappedMessages = chatHistory.messages.map(mapStoredMessage)
|
||||
const shouldPreserveActiveStreamingMessage =
|
||||
@@ -497,7 +518,6 @@ export function useChat(
|
||||
}
|
||||
|
||||
if (activeStreamId && !sendingRef.current) {
|
||||
abortControllerRef.current?.abort()
|
||||
const gen = ++streamGenRef.current
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
@@ -508,6 +528,7 @@ export function useChat(
|
||||
const assistantId = crypto.randomUUID()
|
||||
|
||||
const reconnect = async () => {
|
||||
let reconnectFailed = false
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
@@ -515,14 +536,8 @@ export function useChat(
|
||||
const streamStatus = snapshot?.status ?? ''
|
||||
|
||||
if (batchEvents.length === 0 && streamStatus === 'unknown') {
|
||||
const cid = chatIdRef.current
|
||||
if (cid) {
|
||||
fetch(stopPathRef.current, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId: cid, streamId: activeStreamId, content: '' }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
reconnectFailed = true
|
||||
setError(RECONNECT_TAIL_ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -550,6 +565,7 @@ export function useChat(
|
||||
{ signal: abortController.signal }
|
||||
)
|
||||
if (!sseRes.ok || !sseRes.body) {
|
||||
reconnectFailed = true
|
||||
logger.warn('SSE tail reconnect returned no readable body', {
|
||||
status: sseRes.status,
|
||||
streamId: activeStreamId,
|
||||
@@ -565,6 +581,7 @@ export function useChat(
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error && err.name === 'AbortError')) {
|
||||
reconnectFailed = true
|
||||
logger.warn('SSE tail failed during reconnect', err)
|
||||
setError(RECONNECT_TAIL_ERROR)
|
||||
}
|
||||
@@ -575,13 +592,21 @@ export function useChat(
|
||||
},
|
||||
})
|
||||
|
||||
await processSSEStreamRef.current(combinedStream.getReader(), assistantId, gen)
|
||||
const hadStreamError = await processSSEStreamRef.current(
|
||||
combinedStream.getReader(),
|
||||
assistantId,
|
||||
gen
|
||||
)
|
||||
if (hadStreamError) {
|
||||
reconnectFailed = true
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
reconnectFailed = true
|
||||
} finally {
|
||||
setIsReconnecting(false)
|
||||
if (streamGenRef.current === gen) {
|
||||
finalizeRef.current()
|
||||
finalizeRef.current(reconnectFailed ? { error: true } : undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,7 +644,34 @@ export function useChat(
|
||||
return b
|
||||
}
|
||||
|
||||
const appendInlineErrorTag = (tag: string) => {
|
||||
if (runningText.includes(tag)) return
|
||||
const tb = ensureTextBlock()
|
||||
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
|
||||
tb.content = `${tb.content ?? ''}${prefix}${tag}`
|
||||
if (activeSubagent) tb.subagent = activeSubagent
|
||||
runningText += `${prefix}${tag}`
|
||||
streamingContentRef.current = runningText
|
||||
flush()
|
||||
}
|
||||
|
||||
const buildInlineErrorTag = (payload: SSEPayload) => {
|
||||
const data = getPayloadData(payload) as Record<string, unknown> | undefined
|
||||
const message =
|
||||
(data?.displayMessage as string | undefined) ||
|
||||
payload.error ||
|
||||
'An unexpected error occurred'
|
||||
const provider = (data?.provider as string | undefined) || undefined
|
||||
const code = (data?.code as string | undefined) || undefined
|
||||
return `<mothership-error>${JSON.stringify({
|
||||
message,
|
||||
...(code ? { code } : {}),
|
||||
...(provider ? { provider } : {}),
|
||||
})}</mothership-error>`
|
||||
}
|
||||
|
||||
const isStale = () => expectedGen !== undefined && streamGenRef.current !== expectedGen
|
||||
let sawStreamError = false
|
||||
|
||||
const flush = () => {
|
||||
if (isStale()) return
|
||||
@@ -644,12 +696,9 @@ export function useChat(
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (isStale()) {
|
||||
reader.cancel().catch(() => {})
|
||||
break
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (isStale()) continue
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
@@ -1113,21 +1162,20 @@ export function useChat(
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
sawStreamError = true
|
||||
setError(parsed.error || 'An error occurred')
|
||||
appendInlineErrorTag(buildInlineErrorTag(parsed))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isStale()) {
|
||||
reader.cancel().catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (streamReaderRef.current === reader) {
|
||||
streamReaderRef.current = null
|
||||
}
|
||||
}
|
||||
return sawStreamError
|
||||
},
|
||||
[workspaceId, queryClient, addResource, removeResource]
|
||||
)
|
||||
@@ -1354,7 +1402,10 @@ export function useChat(
|
||||
|
||||
if (!response.body) throw new Error('No response body')
|
||||
|
||||
await processSSEStream(response.body.getReader(), assistantId, gen)
|
||||
const hadStreamError = await processSSEStream(response.body.getReader(), assistantId, gen)
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize(hadStreamError ? { error: true } : undefined)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message')
|
||||
@@ -1363,9 +1414,6 @@ export function useChat(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize()
|
||||
}
|
||||
},
|
||||
[workspaceId, queryClient, processSSEStream, finalize]
|
||||
)
|
||||
@@ -1387,6 +1435,25 @@ export function useChat(
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
|
||||
const updated = msg.contentBlocks!.map((block) => {
|
||||
if (block.toolCall?.status !== 'executing') return block
|
||||
return {
|
||||
...block,
|
||||
toolCall: {
|
||||
...block.toolCall,
|
||||
status: 'cancelled' as const,
|
||||
displayTitle: 'Stopped by user',
|
||||
},
|
||||
}
|
||||
})
|
||||
updated.push({ type: 'stopped' as const })
|
||||
return { ...msg, contentBlocks: updated }
|
||||
})
|
||||
)
|
||||
|
||||
if (sid) {
|
||||
fetch('/api/copilot/chat/abort', {
|
||||
method: 'POST',
|
||||
@@ -1410,25 +1477,6 @@ export function useChat(
|
||||
streamingFileRef.current = null
|
||||
setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
|
||||
const updated = msg.contentBlocks!.map((block) => {
|
||||
if (block.toolCall?.status !== 'executing') return block
|
||||
return {
|
||||
...block,
|
||||
toolCall: {
|
||||
...block.toolCall,
|
||||
status: 'cancelled' as const,
|
||||
displayTitle: 'Stopped by user',
|
||||
},
|
||||
}
|
||||
})
|
||||
updated.push({ type: 'stopped' as const })
|
||||
return { ...msg, contentBlocks: updated }
|
||||
})
|
||||
)
|
||||
|
||||
const execState = useExecutionStore.getState()
|
||||
const consoleStore = useTerminalConsoleStore.getState()
|
||||
for (const [workflowId, wfExec] of execState.workflowExecutions) {
|
||||
@@ -1500,7 +1548,6 @@ export function useChat(
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamReaderRef.current?.cancel().catch(() => {})
|
||||
streamReaderRef.current = null
|
||||
abortControllerRef.current = null
|
||||
streamGenRef.current++
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Banner } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useStopImpersonating } from '@/hooks/queries/admin-users'
|
||||
|
||||
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
|
||||
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
|
||||
}
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
const { data: session, isPending } = useSession()
|
||||
const stopImpersonating = useStopImpersonating()
|
||||
const [isRedirecting, setIsRedirecting] = useState(false)
|
||||
const userLabel = session?.user?.name || 'this user'
|
||||
const userEmail = session?.user?.email
|
||||
|
||||
if (isPending || !session?.session?.impersonatedBy) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner
|
||||
variant='destructive'
|
||||
text={getImpersonationBannerText(userLabel, userEmail)}
|
||||
textClassName='text-red-700 dark:text-red-300'
|
||||
actionLabel={
|
||||
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
|
||||
}
|
||||
actionVariant='destructive'
|
||||
actionDisabled={stopImpersonating.isPending || isRedirecting}
|
||||
onAction={() =>
|
||||
stopImpersonating.mutate(undefined, {
|
||||
onError: () => {
|
||||
setIsRedirecting(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsRedirecting(true)
|
||||
window.location.assign('/workspace')
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||
@@ -11,16 +13,20 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||
<ImpersonationBanner />
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
<div className='flex min-h-0 flex-1'>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavTour />
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
|
||||
@@ -36,7 +36,7 @@ function WorkflowsListInner({
|
||||
searchQuery: string
|
||||
segmentDurationMs: number
|
||||
}) {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const workflows = useWorkflowRegistry((s) => s.workflows)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-2)] dark:bg-[var(--surface-1)]'>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
|
||||
@@ -146,7 +147,14 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
|
||||
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||
const lastAnchorIndicesRef = useRef<Record<string, number>>({})
|
||||
|
||||
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
|
||||
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
workflowIds: s.workflowIds,
|
||||
searchQuery: s.searchQuery,
|
||||
toggleWorkflowId: s.toggleWorkflowId,
|
||||
timeRange: s.timeRange,
|
||||
}))
|
||||
)
|
||||
|
||||
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
@@ -195,7 +196,25 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
resetFilters,
|
||||
} = useFilterStore()
|
||||
} = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
level: s.level,
|
||||
setLevel: s.setLevel,
|
||||
workflowIds: s.workflowIds,
|
||||
setWorkflowIds: s.setWorkflowIds,
|
||||
folderIds: s.folderIds,
|
||||
setFolderIds: s.setFolderIds,
|
||||
triggers: s.triggers,
|
||||
setTriggers: s.setTriggers,
|
||||
timeRange: s.timeRange,
|
||||
setTimeRange: s.setTimeRange,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
setDateRange: s.setDateRange,
|
||||
clearDateRange: s.clearDateRange,
|
||||
resetFilters: s.resetFilters,
|
||||
}))
|
||||
)
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Bell,
|
||||
Button,
|
||||
@@ -230,7 +231,30 @@ export default function Logs() {
|
||||
setTimeRange,
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
} = useFilterStore()
|
||||
} = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
setWorkspaceId: s.setWorkspaceId,
|
||||
initializeFromURL: s.initializeFromURL,
|
||||
timeRange: s.timeRange,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
level: s.level,
|
||||
workflowIds: s.workflowIds,
|
||||
folderIds: s.folderIds,
|
||||
setWorkflowIds: s.setWorkflowIds,
|
||||
setSearchQuery: s.setSearchQuery,
|
||||
triggers: s.triggers,
|
||||
viewMode: s.viewMode,
|
||||
setViewMode: s.setViewMode,
|
||||
resetFilters: s.resetFilters,
|
||||
setLevel: s.setLevel,
|
||||
setFolderIds: s.setFolderIds,
|
||||
setTriggers: s.setTriggers,
|
||||
setTimeRange: s.setTimeRange,
|
||||
setDateRange: s.setDateRange,
|
||||
clearDateRange: s.clearDateRange,
|
||||
}))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceId(workspaceId)
|
||||
@@ -1133,7 +1157,25 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
resetFilters,
|
||||
} = useFilterStore()
|
||||
} = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
level: s.level,
|
||||
setLevel: s.setLevel,
|
||||
workflowIds: s.workflowIds,
|
||||
setWorkflowIds: s.setWorkflowIds,
|
||||
folderIds: s.folderIds,
|
||||
setFolderIds: s.setFolderIds,
|
||||
triggers: s.triggers,
|
||||
setTriggers: s.setTriggers,
|
||||
timeRange: s.timeRange,
|
||||
setTimeRange: s.setTimeRange,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
setDateRange: s.setDateRange,
|
||||
clearDateRange: s.clearDateRange,
|
||||
resetFilters: s.resetFilters,
|
||||
}))
|
||||
)
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
useAdminUsers,
|
||||
useBanUser,
|
||||
useImpersonateUser,
|
||||
useSetUserRole,
|
||||
useUnbanUser,
|
||||
} from '@/hooks/queries/admin-users'
|
||||
@@ -28,6 +29,7 @@ export function Admin() {
|
||||
const setUserRole = useSetUserRole()
|
||||
const banUser = useBanUser()
|
||||
const unbanUser = useUnbanUser()
|
||||
const impersonateUser = useImpersonateUser()
|
||||
|
||||
const [workflowId, setWorkflowId] = useState('')
|
||||
const [usersOffset, setUsersOffset] = useState(0)
|
||||
@@ -35,6 +37,8 @@ export function Admin() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [banUserId, setBanUserId] = useState<string | null>(null)
|
||||
const [banReason, setBanReason] = useState('')
|
||||
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
|
||||
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
@@ -67,6 +71,29 @@ export function Admin() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleImpersonate = (userId: string) => {
|
||||
setImpersonationGuardError(null)
|
||||
if (session?.user?.role !== 'admin') {
|
||||
setImpersonatingUserId(null)
|
||||
setImpersonationGuardError('Only admins can impersonate users.')
|
||||
return
|
||||
}
|
||||
|
||||
setImpersonatingUserId(userId)
|
||||
impersonateUser.reset()
|
||||
impersonateUser.mutate(
|
||||
{ userId },
|
||||
{
|
||||
onError: () => {
|
||||
setImpersonatingUserId(null)
|
||||
},
|
||||
onSuccess: () => {
|
||||
window.location.assign('/workspace')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const pendingUserIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
|
||||
@@ -75,6 +102,9 @@ export function Admin() {
|
||||
ids.add((banUser.variables as { userId: string }).userId)
|
||||
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
|
||||
ids.add((unbanUser.variables as { userId: string }).userId)
|
||||
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
|
||||
ids.add((impersonateUser.variables as { userId: string }).userId)
|
||||
if (impersonatingUserId) ids.add(impersonatingUserId)
|
||||
return ids
|
||||
}, [
|
||||
setUserRole.isPending,
|
||||
@@ -83,6 +113,9 @@ export function Admin() {
|
||||
banUser.variables,
|
||||
unbanUser.isPending,
|
||||
unbanUser.variables,
|
||||
impersonateUser.isPending,
|
||||
impersonateUser.variables,
|
||||
impersonatingUserId,
|
||||
])
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[24px]'>
|
||||
@@ -152,9 +185,15 @@ export function Admin() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(setUserRole.error || banUser.error || unbanUser.error) && (
|
||||
{(setUserRole.error ||
|
||||
banUser.error ||
|
||||
unbanUser.error ||
|
||||
impersonateUser.error ||
|
||||
impersonationGuardError) && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>
|
||||
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
|
||||
{impersonationGuardError ||
|
||||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
|
||||
?.message ||
|
||||
'Action failed. Please try again.'}
|
||||
</p>
|
||||
)}
|
||||
@@ -175,7 +214,7 @@ export function Admin() {
|
||||
<span className='flex-1'>Email</span>
|
||||
<span className='w-[80px]'>Role</span>
|
||||
<span className='w-[80px]'>Status</span>
|
||||
<span className='w-[180px] text-right'>Actions</span>
|
||||
<span className='w-[250px] text-right'>Actions</span>
|
||||
</div>
|
||||
|
||||
{usersData.users.length === 0 && (
|
||||
@@ -206,9 +245,22 @@ export function Admin() {
|
||||
<Badge variant='green'>Active</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className='flex w-[180px] justify-end gap-[4px]'>
|
||||
<span className='flex w-[250px] justify-end gap-[4px]'>
|
||||
{u.id !== session?.user?.id && (
|
||||
<>
|
||||
<Button
|
||||
variant='active'
|
||||
className='h-[28px] px-[8px] text-[12px]'
|
||||
onClick={() => handleImpersonate(u.id)}
|
||||
disabled={pendingUserIds.has(u.id)}
|
||||
>
|
||||
{impersonatingUserId === u.id ||
|
||||
(impersonateUser.isPending &&
|
||||
(impersonateUser.variables as { userId?: string } | undefined)
|
||||
?.userId === u.id)
|
||||
? 'Switching...'
|
||||
: 'Impersonate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='active'
|
||||
className='h-[28px] px-[8px] text-[12px]'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Camera, Check, Pencil } from 'lucide-react'
|
||||
import { Camera, Check, Info, Pencil } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
@@ -375,7 +376,22 @@ export function General() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Automatically connect blocks when dropped near each other</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/auto-connect-on-drop.mp4'
|
||||
alt='Auto-connect on drop example'
|
||||
loop={false}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='auto-connect'
|
||||
checked={settings?.autoConnect ?? true}
|
||||
@@ -384,7 +400,21 @@ export function General() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='error-notifications'>Workflow error notifications</Label>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Show error popups on blocks when a workflow run fails</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/canvas-error-notification.mp4'
|
||||
alt='Canvas error notification example'
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='error-notifications'
|
||||
checked={settings?.errorNotificationsEnabled ?? true}
|
||||
@@ -461,7 +491,7 @@ export function General() {
|
||||
)}
|
||||
{isHosted && (
|
||||
<Button
|
||||
onClick={() => window.open('/?from=settings', '_blank', 'noopener,noreferrer')}
|
||||
onClick={() => window.open('/?home', '_blank', 'noopener,noreferrer')}
|
||||
variant='active'
|
||||
className='ml-auto'
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Card,
|
||||
Connections,
|
||||
HexSimple,
|
||||
@@ -38,7 +37,6 @@ export type SettingsSection =
|
||||
| 'skills'
|
||||
| 'workflow-mcp-servers'
|
||||
| 'inbox'
|
||||
| 'docs'
|
||||
| 'admin'
|
||||
| 'recently-deleted'
|
||||
|
||||
@@ -156,14 +154,6 @@ export const allNavigationItems: NavigationItem[] = [
|
||||
requiresEnterprise: true,
|
||||
selfHostedOverride: isSSOEnabled,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
icon: BookOpen,
|
||||
section: 'system',
|
||||
requiresHosted: true,
|
||||
externalUrl: 'https://docs.sim.ai',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
label: 'Admin',
|
||||
|
||||
@@ -196,6 +196,18 @@ export function Table({
|
||||
const columnWidthsRef = useRef(columnWidths)
|
||||
columnWidthsRef.current = columnWidths
|
||||
const [resizingColumn, setResizingColumn] = useState<string | null>(null)
|
||||
const [columnOrder, setColumnOrder] = useState<string[] | null>(null)
|
||||
const columnOrderRef = useRef(columnOrder)
|
||||
columnOrderRef.current = columnOrder
|
||||
const [dragColumnName, setDragColumnName] = useState<string | null>(null)
|
||||
const dragColumnNameRef = useRef(dragColumnName)
|
||||
dragColumnNameRef.current = dragColumnName
|
||||
const [dropTargetColumnName, setDropTargetColumnName] = useState<string | null>(null)
|
||||
const dropTargetColumnNameRef = useRef(dropTargetColumnName)
|
||||
dropTargetColumnNameRef.current = dropTargetColumnName
|
||||
const [dropSide, setDropSide] = useState<'left' | 'right'>('left')
|
||||
const dropSideRef = useRef(dropSide)
|
||||
dropSideRef.current = dropSide
|
||||
const metadataSeededRef = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -239,6 +251,23 @@ export function Table({
|
||||
[tableData?.schema?.columns]
|
||||
)
|
||||
|
||||
const displayColumns = useMemo(() => {
|
||||
if (!columnOrder || columnOrder.length === 0) return columns
|
||||
const colMap = new Map(columns.map((c) => [c.name, c]))
|
||||
const ordered: ColumnDefinition[] = []
|
||||
for (const name of columnOrder) {
|
||||
const col = colMap.get(name)
|
||||
if (col) {
|
||||
ordered.push(col)
|
||||
colMap.delete(name)
|
||||
}
|
||||
}
|
||||
for (const col of colMap.values()) {
|
||||
ordered.push(col)
|
||||
}
|
||||
return ordered
|
||||
}, [columns, columnOrder])
|
||||
|
||||
const maxPosition = useMemo(() => (rows.length > 0 ? rows[rows.length - 1].position : -1), [rows])
|
||||
const maxPositionRef = useRef(maxPosition)
|
||||
maxPositionRef.current = maxPosition
|
||||
@@ -258,23 +287,23 @@ export function Table({
|
||||
[selectionAnchor, selectionFocus]
|
||||
)
|
||||
|
||||
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : columns.length
|
||||
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : displayColumns.length
|
||||
const tableWidth = useMemo(() => {
|
||||
const colsWidth = isLoadingTable
|
||||
? displayColCount * COL_WIDTH
|
||||
: columns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
|
||||
: displayColumns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
|
||||
return CHECKBOX_COL_WIDTH + colsWidth + ADD_COL_WIDTH
|
||||
}, [isLoadingTable, displayColCount, columns, columnWidths])
|
||||
}, [isLoadingTable, displayColCount, displayColumns, columnWidths])
|
||||
|
||||
const resizeIndicatorLeft = useMemo(() => {
|
||||
if (!resizingColumn) return 0
|
||||
let left = CHECKBOX_COL_WIDTH
|
||||
for (const col of columns) {
|
||||
for (const col of displayColumns) {
|
||||
left += columnWidths[col.name] ?? COL_WIDTH
|
||||
if (col.name === resizingColumn) return left
|
||||
}
|
||||
return 0
|
||||
}, [resizingColumn, columns, columnWidths])
|
||||
}, [resizingColumn, displayColumns, columnWidths])
|
||||
|
||||
const isAllRowsSelected = useMemo(() => {
|
||||
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
|
||||
@@ -289,14 +318,15 @@ export function Table({
|
||||
normalizedSelection.startRow === 0 &&
|
||||
normalizedSelection.endRow === maxPosition &&
|
||||
normalizedSelection.startCol === 0 &&
|
||||
normalizedSelection.endCol === columns.length - 1
|
||||
normalizedSelection.endCol === displayColumns.length - 1
|
||||
)
|
||||
}, [checkedRows, normalizedSelection, maxPosition, columns.length, rows])
|
||||
}, [checkedRows, normalizedSelection, maxPosition, displayColumns.length, rows])
|
||||
|
||||
const isAllRowsSelectedRef = useRef(isAllRowsSelected)
|
||||
isAllRowsSelectedRef.current = isAllRowsSelected
|
||||
|
||||
const columnsRef = useRef(columns)
|
||||
const columnsRef = useRef(displayColumns)
|
||||
const schemaColumnsRef = useRef(columns)
|
||||
const rowsRef = useRef(rows)
|
||||
const selectionAnchorRef = useRef(selectionAnchor)
|
||||
const selectionFocusRef = useRef(selectionFocus)
|
||||
@@ -304,7 +334,8 @@ export function Table({
|
||||
const checkedRowsRef = useRef(checkedRows)
|
||||
checkedRowsRef.current = checkedRows
|
||||
|
||||
columnsRef.current = columns
|
||||
columnsRef.current = displayColumns
|
||||
schemaColumnsRef.current = columns
|
||||
rowsRef.current = rows
|
||||
selectionAnchorRef.current = selectionAnchor
|
||||
selectionFocusRef.current = selectionFocus
|
||||
@@ -329,10 +360,20 @@ export function Table({
|
||||
const columnRename = useInlineRename({
|
||||
onSave: (columnName, newName) => {
|
||||
pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName })
|
||||
setColumnWidths((prev) => {
|
||||
if (!(columnName in prev)) return prev
|
||||
return { ...prev, [newName]: prev[columnName] }
|
||||
})
|
||||
let updatedWidths = columnWidthsRef.current
|
||||
if (columnName in updatedWidths) {
|
||||
const { [columnName]: width, ...rest } = updatedWidths
|
||||
updatedWidths = { ...rest, [newName]: width }
|
||||
setColumnWidths(updatedWidths)
|
||||
}
|
||||
const updatedOrder = columnOrderRef.current?.map((n) => (n === columnName ? newName : n))
|
||||
if (updatedOrder) {
|
||||
setColumnOrder(updatedOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: updatedWidths,
|
||||
columnOrder: updatedOrder,
|
||||
})
|
||||
}
|
||||
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
|
||||
},
|
||||
})
|
||||
@@ -607,11 +648,58 @@ export function Table({
|
||||
updateMetadataRef.current({ columnWidths: columnWidthsRef.current })
|
||||
}, [])
|
||||
|
||||
const handleColumnDragStart = useCallback((columnName: string) => {
|
||||
setDragColumnName(columnName)
|
||||
}, [])
|
||||
|
||||
const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => {
|
||||
if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return
|
||||
setDropTargetColumnName(columnName)
|
||||
setDropSide(side)
|
||||
}, [])
|
||||
|
||||
const handleColumnDragEnd = useCallback(() => {
|
||||
const dragged = dragColumnNameRef.current
|
||||
if (!dragged) return
|
||||
const target = dropTargetColumnNameRef.current
|
||||
const side = dropSideRef.current
|
||||
if (target && dragged !== target) {
|
||||
const cols = columnsRef.current
|
||||
const currentOrder = columnOrderRef.current ?? cols.map((c) => c.name)
|
||||
const fromIndex = currentOrder.indexOf(dragged)
|
||||
const toIndex = currentOrder.indexOf(target)
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
const newOrder = currentOrder.filter((n) => n !== dragged)
|
||||
let insertIndex = newOrder.indexOf(target)
|
||||
if (side === 'right') insertIndex += 1
|
||||
newOrder.splice(insertIndex, 0, dragged)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
setDragColumnName(null)
|
||||
setDropTargetColumnName(null)
|
||||
}, [])
|
||||
|
||||
const handleColumnDragLeave = useCallback(() => {
|
||||
dropTargetColumnNameRef.current = null
|
||||
setDropTargetColumnName(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableData?.metadata?.columnWidths || metadataSeededRef.current) return
|
||||
if (!tableData?.metadata || metadataSeededRef.current) return
|
||||
if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return
|
||||
metadataSeededRef.current = true
|
||||
setColumnWidths(tableData.metadata.columnWidths)
|
||||
}, [tableData?.metadata?.columnWidths])
|
||||
if (tableData.metadata.columnWidths) {
|
||||
setColumnWidths(tableData.metadata.columnWidths)
|
||||
}
|
||||
if (tableData.metadata.columnOrder) {
|
||||
setColumnOrder(tableData.metadata.columnOrder)
|
||||
}
|
||||
}, [tableData?.metadata])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseUp = () => {
|
||||
@@ -1214,7 +1302,7 @@ export function Table({
|
||||
}, [])
|
||||
|
||||
const generateColumnName = useCallback(() => {
|
||||
const existing = columnsRef.current.map((c) => c.name.toLowerCase())
|
||||
const existing = schemaColumnsRef.current.map((c) => c.name.toLowerCase())
|
||||
let name = 'untitled'
|
||||
let i = 2
|
||||
while (existing.includes(name.toLowerCase())) {
|
||||
@@ -1226,7 +1314,7 @@ export function Table({
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
const name = generateColumnName()
|
||||
const position = columnsRef.current.length
|
||||
const position = schemaColumnsRef.current.length
|
||||
addColumnMutation.mutate(
|
||||
{ name, type: 'string' },
|
||||
{
|
||||
@@ -1250,9 +1338,30 @@ export function Table({
|
||||
updateColumnMutation.mutate({ columnName, updates: { type: newType } })
|
||||
}, [])
|
||||
|
||||
const insertColumnInOrder = useCallback(
|
||||
(anchorColumn: string, newColumn: string, side: 'left' | 'right') => {
|
||||
const order = columnOrderRef.current
|
||||
if (!order) return
|
||||
const newOrder = [...order]
|
||||
let anchorIdx = newOrder.indexOf(anchorColumn)
|
||||
if (anchorIdx === -1) {
|
||||
newOrder.push(anchorColumn)
|
||||
anchorIdx = newOrder.length - 1
|
||||
}
|
||||
const insertIdx = anchorIdx + (side === 'right' ? 1 : 0)
|
||||
newOrder.splice(insertIdx, 0, newColumn)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleInsertColumnLeft = useCallback(
|
||||
(columnName: string) => {
|
||||
const index = columnsRef.current.findIndex((c) => c.name === columnName)
|
||||
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
|
||||
if (index === -1) return
|
||||
const name = generateColumnName()
|
||||
addColumnMutation.mutate(
|
||||
@@ -1260,16 +1369,17 @@ export function Table({
|
||||
{
|
||||
onSuccess: () => {
|
||||
pushUndoRef.current({ type: 'create-column', columnName: name, position: index })
|
||||
insertColumnInOrder(columnName, name, 'left')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[generateColumnName]
|
||||
[generateColumnName, insertColumnInOrder]
|
||||
)
|
||||
|
||||
const handleInsertColumnRight = useCallback(
|
||||
(columnName: string) => {
|
||||
const index = columnsRef.current.findIndex((c) => c.name === columnName)
|
||||
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
|
||||
if (index === -1) return
|
||||
const name = generateColumnName()
|
||||
const position = index + 1
|
||||
@@ -1278,11 +1388,12 @@ export function Table({
|
||||
{
|
||||
onSuccess: () => {
|
||||
pushUndoRef.current({ type: 'create-column', columnName: name, position })
|
||||
insertColumnInOrder(columnName, name, 'right')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[generateColumnName]
|
||||
[generateColumnName, insertColumnInOrder]
|
||||
)
|
||||
|
||||
const handleToggleUnique = useCallback((columnName: string) => {
|
||||
@@ -1310,8 +1421,20 @@ export function Table({
|
||||
|
||||
const handleDeleteColumnConfirm = useCallback(() => {
|
||||
if (!deletingColumn) return
|
||||
deleteColumnMutation.mutate(deletingColumn)
|
||||
const columnToDelete = deletingColumn
|
||||
setDeletingColumn(null)
|
||||
deleteColumnMutation.mutate(columnToDelete, {
|
||||
onSuccess: () => {
|
||||
const order = columnOrderRef.current
|
||||
if (!order) return
|
||||
const newOrder = order.filter((n) => n !== columnToDelete)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [deletingColumn])
|
||||
|
||||
const handleSortChange = useCallback((column: string, direction: SortDirection) => {
|
||||
@@ -1327,13 +1450,13 @@ export function Table({
|
||||
}, [])
|
||||
const columnOptions = useMemo<ColumnOption[]>(
|
||||
() =>
|
||||
columns.map((col) => ({
|
||||
displayColumns.map((col) => ({
|
||||
id: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
icon: COLUMN_TYPE_ICONS[col.type],
|
||||
})),
|
||||
[columns]
|
||||
[displayColumns]
|
||||
)
|
||||
|
||||
const tableDataRef = useRef(tableData)
|
||||
@@ -1404,8 +1527,8 @@ export function Table({
|
||||
)
|
||||
|
||||
const filterElement = useMemo(
|
||||
() => <TableFilter columns={columns} onApply={handleFilterApply} />,
|
||||
[columns, handleFilterApply]
|
||||
() => <TableFilter columns={displayColumns} onApply={handleFilterApply} />,
|
||||
[displayColumns, handleFilterApply]
|
||||
)
|
||||
|
||||
const activeSortState = useMemo(() => {
|
||||
@@ -1501,7 +1624,7 @@ export function Table({
|
||||
<col style={{ width: ADD_COL_WIDTH }} />
|
||||
</colgroup>
|
||||
) : (
|
||||
<TableColGroup columns={columns} columnWidths={columnWidths} />
|
||||
<TableColGroup columns={displayColumns} columnWidths={columnWidths} />
|
||||
)}
|
||||
<thead className='sticky top-0 z-10'>
|
||||
{isLoadingTable ? (
|
||||
@@ -1532,7 +1655,7 @@ export function Table({
|
||||
checked={isAllRowsSelected}
|
||||
onCheckedChange={handleSelectAllToggle}
|
||||
/>
|
||||
{columns.map((column) => (
|
||||
{displayColumns.map((column) => (
|
||||
<ColumnHeaderMenu
|
||||
key={column.name}
|
||||
column={column}
|
||||
@@ -1553,6 +1676,13 @@ export function Table({
|
||||
onResizeStart={handleColumnResizeStart}
|
||||
onResize={handleColumnResize}
|
||||
onResizeEnd={handleColumnResizeEnd}
|
||||
isDragging={dragColumnName === column.name}
|
||||
isDropTarget={dropTargetColumnName === column.name}
|
||||
dropSide={dropTargetColumnName === column.name ? dropSide : undefined}
|
||||
onDragStart={handleColumnDragStart}
|
||||
onDragOver={handleColumnDragOver}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
/>
|
||||
))}
|
||||
{userPermissions.canEdit && (
|
||||
@@ -1578,7 +1708,7 @@ export function Table({
|
||||
<PositionGapRows
|
||||
count={gapCount}
|
||||
startPosition={prevPosition + 1}
|
||||
columns={columns}
|
||||
columns={displayColumns}
|
||||
normalizedSelection={normalizedSelection}
|
||||
checkedRows={checkedRows}
|
||||
firstRowUnderHeader={prevPosition === -1}
|
||||
@@ -1589,7 +1719,7 @@ export function Table({
|
||||
)}
|
||||
<DataRow
|
||||
row={row}
|
||||
columns={columns}
|
||||
columns={displayColumns}
|
||||
rowIndex={row.position}
|
||||
isFirstRow={row.position === 0}
|
||||
editingColumnName={
|
||||
@@ -2445,6 +2575,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResizeStart,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
isDragging,
|
||||
isDropTarget,
|
||||
dropSide,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDragLeave,
|
||||
}: {
|
||||
column: ColumnDefinition
|
||||
readOnly?: boolean
|
||||
@@ -2462,6 +2599,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResizeStart: (columnName: string) => void
|
||||
onResize: (columnName: string, width: number) => void
|
||||
onResizeEnd: () => void
|
||||
isDragging?: boolean
|
||||
isDropTarget?: boolean
|
||||
dropSide?: 'left' | 'right'
|
||||
onDragStart?: (columnName: string) => void
|
||||
onDragOver?: (columnName: string, side: 'left' | 'right') => void
|
||||
onDragEnd?: () => void
|
||||
onDragLeave?: () => void
|
||||
}) {
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -2503,8 +2647,68 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
[column.name, onResizeStart, onResize, onResizeEnd]
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (readOnly || isRenaming) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', column.name)
|
||||
onDragStart?.(column.name)
|
||||
},
|
||||
[column.name, readOnly, isRenaming, onDragStart]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const midX = rect.left + rect.width / 2
|
||||
const side = e.clientX < midX ? 'left' : 'right'
|
||||
onDragOver?.(column.name, side)
|
||||
},
|
||||
[column.name, onDragOver]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
onDragEnd?.()
|
||||
}, [onDragEnd])
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const th = e.currentTarget as HTMLElement
|
||||
const related = e.relatedTarget as Node | null
|
||||
if (related && th.contains(related)) return
|
||||
onDragLeave?.()
|
||||
},
|
||||
[onDragLeave]
|
||||
)
|
||||
|
||||
return (
|
||||
<th className='relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'>
|
||||
<th
|
||||
className={cn(
|
||||
'relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
|
||||
isDragging && 'opacity-40'
|
||||
)}
|
||||
draggable={!readOnly && !isRenaming}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{isDropTarget && dropSide === 'left' && (
|
||||
<div className='pointer-events-none absolute top-0 bottom-0 left-[-1px] z-10 w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
{isDropTarget && dropSide === 'right' && (
|
||||
<div className='pointer-events-none absolute top-0 right-[-1px] bottom-0 z-10 w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<div className='flex h-full w-full min-w-0 items-center px-[8px] py-[7px]'>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
@@ -2533,7 +2737,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-full w-full min-w-0 cursor-pointer items-center px-[8px] py-[7px] outline-none'
|
||||
className='flex h-full w-full min-w-0 cursor-grab items-center px-[8px] py-[7px] outline-none active:cursor-grabbing'
|
||||
>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
<span className='ml-[6px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -2589,6 +2793,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
)}
|
||||
<div
|
||||
className='-right-[3px] absolute top-0 z-[1] h-full w-[6px] cursor-col-resize'
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.stopPropagation()}
|
||||
onPointerDown={handleResizePointerDown}
|
||||
/>
|
||||
</th>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -288,9 +289,14 @@ function TemplateCardInner({
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
<Image
|
||||
src={authorImageUrl}
|
||||
alt={author}
|
||||
width={20}
|
||||
height={20}
|
||||
className='flex-shrink-0 rounded-full object-cover'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-7)]'>
|
||||
<User className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -220,7 +221,7 @@ interface StartInputFormatField {
|
||||
* position across sessions using the floating chat store.
|
||||
*/
|
||||
export function Chat() {
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate)
|
||||
const setSubBlockValue = useSubBlockStore((state) => state.setValue)
|
||||
@@ -242,7 +243,26 @@ export function Chat() {
|
||||
getConversationId,
|
||||
clearChat,
|
||||
exportChatCSV,
|
||||
} = useChatStore()
|
||||
} = useChatStore(
|
||||
useShallow((s) => ({
|
||||
isChatOpen: s.isChatOpen,
|
||||
chatPosition: s.chatPosition,
|
||||
chatWidth: s.chatWidth,
|
||||
chatHeight: s.chatHeight,
|
||||
setIsChatOpen: s.setIsChatOpen,
|
||||
setChatPosition: s.setChatPosition,
|
||||
setChatDimensions: s.setChatDimensions,
|
||||
messages: s.messages,
|
||||
addMessage: s.addMessage,
|
||||
selectedWorkflowOutputs: s.selectedWorkflowOutputs,
|
||||
setSelectedWorkflowOutput: s.setSelectedWorkflowOutput,
|
||||
appendMessageContent: s.appendMessageContent,
|
||||
finalizeMessageStream: s.finalizeMessageStream,
|
||||
getConversationId: s.getConversationId,
|
||||
clearChat: s.clearChat,
|
||||
exportChatCSV: s.exportChatCSV,
|
||||
}))
|
||||
)
|
||||
|
||||
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
|
||||
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
@@ -80,7 +81,14 @@ export function OutputSelect({
|
||||
maxHeight = 200,
|
||||
}: OutputSelectProps) {
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
|
||||
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore(
|
||||
useShallow((s) => ({
|
||||
isShowingDiff: s.isShowingDiff,
|
||||
isDiffReady: s.isDiffReady,
|
||||
hasActiveDiff: s.hasActiveDiff,
|
||||
baselineWorkflow: s.baselineWorkflow,
|
||||
}))
|
||||
)
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
workflowId ? state.workflowValues[workflowId] : null
|
||||
)
|
||||
|
||||
@@ -58,7 +58,7 @@ const commands: CommandItem[] = [
|
||||
export function CommandList() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { open: openSearchModal } = useSearchModalStore()
|
||||
const openSearchModal = useSearchModalStore((s) => s.open)
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
@@ -179,6 +179,7 @@ export function CommandList() {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-tour='command-list'
|
||||
className='pointer-events-auto flex flex-col gap-[8px]'
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
@@ -195,7 +196,6 @@ export function CommandList() {
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(69%) sepia(0%) saturate(0%) hue-rotate(202deg) brightness(94%) contrast(89%)',
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
|
||||
@@ -85,7 +85,7 @@ export function VariablesInput({
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<VariableAssignment[]>(blockId, subBlockId)
|
||||
const { variables: workflowVariables } = useVariablesStore()
|
||||
const workflowVariables = useVariablesStore((s) => s.variables)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isEqual } from 'lodash'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useToolbarStore } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
@@ -76,7 +77,12 @@ export function useToolbarResize({
|
||||
triggersContentRef,
|
||||
triggersHeaderRef,
|
||||
}: UseToolbarResizeProps) {
|
||||
const { toolbarTriggersHeight, setToolbarTriggersHeight } = useToolbarStore()
|
||||
const { toolbarTriggersHeight, setToolbarTriggersHeight } = useToolbarStore(
|
||||
useShallow((s) => ({
|
||||
toolbarTriggersHeight: s.toolbarTriggersHeight,
|
||||
setToolbarTriggersHeight: s.setToolbarTriggersHeight,
|
||||
}))
|
||||
)
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startYRef = useRef<number>(0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { PANEL_WIDTH } from '@/stores/constants'
|
||||
import { usePanelStore } from '@/stores/panel'
|
||||
|
||||
@@ -13,7 +14,13 @@ const CONTENT_WINDOW_GAP = 8
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function usePanelResize() {
|
||||
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
|
||||
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore(
|
||||
useShallow((s) => ({
|
||||
setPanelWidth: s.setPanelWidth,
|
||||
isResizing: s.isResizing,
|
||||
setIsResizing: s.setIsResizing,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
|
||||
@@ -34,16 +34,9 @@ import { Lock, Unlock, Upload } from '@/components/emcn/icons'
|
||||
import { VariableIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
|
||||
import { ConversationListItem, MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
QueuedMessages,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from '@/app/workspace/[workspaceId]/home/components'
|
||||
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { useAutoScroll, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
|
||||
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -332,13 +325,15 @@ export const Panel = memo(function Panel() {
|
||||
removeFromQueue: copilotRemoveFromQueue,
|
||||
sendNow: copilotSendNow,
|
||||
editQueuedMessage: copilotEditQueuedMessage,
|
||||
} = useChat(workspaceId, copilotChatId, {
|
||||
apiPath: '/api/copilot/chat',
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
onTitleUpdate: loadCopilotChats,
|
||||
onToolResult: handleCopilotToolResult,
|
||||
})
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
copilotChatId,
|
||||
getWorkflowCopilotUseChatOptions({
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
onTitleUpdate: loadCopilotChats,
|
||||
onToolResult: handleCopilotToolResult,
|
||||
})
|
||||
)
|
||||
|
||||
const handleCopilotNewChat = useCallback(() => {
|
||||
if (!activeWorkflowId || !workspaceId) return
|
||||
@@ -403,9 +398,6 @@ export const Panel = memo(function Panel() {
|
||||
[copilotSendMessage]
|
||||
)
|
||||
|
||||
const { ref: copilotScrollRef, scrollToBottom: copilotScrollToBottom } =
|
||||
useAutoScroll(copilotIsSending)
|
||||
|
||||
/**
|
||||
* Mark hydration as complete on mount
|
||||
* This allows React to take over visibility control from CSS
|
||||
@@ -608,7 +600,7 @@ export const Panel = memo(function Panel() {
|
||||
<div className='flex gap-[6px]'>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className='h-[30px] w-[30px] rounded-[5px]'>
|
||||
<Button className='h-[30px] w-[30px] rounded-[5px]' data-tour='panel-menu'>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -668,10 +660,11 @@ export const Panel = memo(function Panel() {
|
||||
</div>
|
||||
|
||||
{/* Deploy and Run */}
|
||||
<div className='flex gap-[6px]'>
|
||||
<div className='flex gap-[6px]' data-tour='deploy-run'>
|
||||
<Deploy activeWorkflowId={activeWorkflowId} userPermissions={userPermissions} />
|
||||
<Button
|
||||
className='h-[30px] gap-[8px] px-[10px]'
|
||||
data-tour='run-button'
|
||||
variant={isExecuting ? 'active' : 'tertiary'}
|
||||
onClick={isExecuting ? cancelWorkflow : () => runWorkflow()}
|
||||
disabled={!isExecuting && isButtonDisabled}
|
||||
@@ -699,6 +692,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
data-tab-button='copilot'
|
||||
data-tour='tab-copilot'
|
||||
>
|
||||
Copilot
|
||||
</Button>
|
||||
@@ -712,6 +706,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'toolbar' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('toolbar')}
|
||||
data-tab-button='toolbar'
|
||||
data-tour='tab-toolbar'
|
||||
>
|
||||
Toolbar
|
||||
</Button>
|
||||
@@ -724,6 +719,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'editor' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('editor')}
|
||||
data-tab-button='editor'
|
||||
data-tour='tab-editor'
|
||||
>
|
||||
Editor
|
||||
</Button>
|
||||
@@ -812,77 +808,21 @@ export const Panel = memo(function Panel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={copilotScrollRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{copilotMessages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-2'>
|
||||
<div className='max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2'>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' && index === copilotMessages.length - 1
|
||||
const isThisStreaming = copilotIsSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === copilotMessages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='group/msg relative pb-3'>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? copilotSendMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0 px-3 pb-3'>
|
||||
<QueuedMessages
|
||||
messageQueue={copilotMessageQueue}
|
||||
onRemove={copilotRemoveFromQueue}
|
||||
onSendNow={copilotSendNow}
|
||||
onEdit={handleCopilotEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleCopilotSubmit}
|
||||
isSending={copilotIsSending}
|
||||
onStopGeneration={copilotStopGeneration}
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
/>
|
||||
</div>
|
||||
<MothershipChat
|
||||
className='min-h-0 flex-1'
|
||||
messages={copilotMessages}
|
||||
isSending={copilotIsSending}
|
||||
onSubmit={handleCopilotSubmit}
|
||||
onStopGeneration={copilotStopGeneration}
|
||||
messageQueue={copilotMessageQueue}
|
||||
onRemoveQueuedMessage={copilotRemoveFromQueue}
|
||||
onSendQueuedMessage={copilotSendNow}
|
||||
onEditQueuedMessage={handleCopilotEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
layout='copilot-view'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -92,17 +93,33 @@ const STRINGS = {
|
||||
* - Uses emcn Input/Code/Combobox components for a consistent UI
|
||||
*/
|
||||
export function Variables() {
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
|
||||
useVariablesStore()
|
||||
useVariablesStore(
|
||||
useShallow((s) => ({
|
||||
isOpen: s.isOpen,
|
||||
position: s.position,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
setIsOpen: s.setIsOpen,
|
||||
setPosition: s.setPosition,
|
||||
setDimensions: s.setDimensions,
|
||||
}))
|
||||
)
|
||||
|
||||
const { getVariablesByWorkflowId } = usePanelVariablesStore()
|
||||
const variables = usePanelVariablesStore((s) => s.variables)
|
||||
|
||||
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
|
||||
useCollaborativeWorkflow()
|
||||
|
||||
const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
|
||||
const workflowVariables = useMemo(
|
||||
() =>
|
||||
activeWorkflowId
|
||||
? Object.values(variables).filter((v) => v.workflowId === activeWorkflowId)
|
||||
: [],
|
||||
[variables, activeWorkflowId]
|
||||
)
|
||||
|
||||
const actualPosition = useMemo(
|
||||
() => getVariablesPosition(position, width, height),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Scan } from 'lucide-react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Button,
|
||||
ChevronDown,
|
||||
@@ -36,7 +37,9 @@ const logger = createLogger('WorkflowControls')
|
||||
export const WorkflowControls = memo(function WorkflowControls() {
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
|
||||
const { mode, setMode } = useCanvasModeStore()
|
||||
const { mode, setMode } = useCanvasModeStore(
|
||||
useShallow((s) => ({ mode: s.mode, setMode: s.setMode }))
|
||||
)
|
||||
const { undo, redo } = useCollaborativeWorkflow()
|
||||
const showWorkflowControls = useShowActionBar()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
@@ -80,13 +83,14 @@ export const WorkflowControls = memo(function WorkflowControls() {
|
||||
}
|
||||
|
||||
if (!showWorkflowControls) {
|
||||
return null
|
||||
return <div data-tour='workflow-controls' className='hidden' />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute bottom-[16px] left-[16px] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]'
|
||||
data-tour='workflow-controls'
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Canvas Mode Selector */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { processStreamingBlockLogs } from '@/lib/tokenization'
|
||||
import {
|
||||
@@ -97,12 +98,27 @@ function normalizeErrorMessage(error: unknown): string {
|
||||
export function useWorkflowExecution() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry(
|
||||
useShallow((s) => ({ activeWorkflowId: s.activeWorkflowId, workflows: s.workflows }))
|
||||
)
|
||||
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } =
|
||||
useTerminalConsoleStore()
|
||||
useTerminalConsoleStore(
|
||||
useShallow((s) => ({
|
||||
toggleConsole: s.toggleConsole,
|
||||
addConsole: s.addConsole,
|
||||
updateConsole: s.updateConsole,
|
||||
cancelRunningEntries: s.cancelRunningEntries,
|
||||
clearExecutionEntries: s.clearExecutionEntries,
|
||||
}))
|
||||
)
|
||||
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
|
||||
const { getAllVariables } = useEnvironmentStore()
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||
const getAllVariables = useEnvironmentStore((s) => s.getAllVariables)
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore(
|
||||
useShallow((s) => ({
|
||||
getVariablesByWorkflowId: s.getVariablesByWorkflowId,
|
||||
variables: s.variables,
|
||||
}))
|
||||
)
|
||||
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } =
|
||||
useCurrentWorkflowExecution()
|
||||
const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { WorkflowTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
|
||||
|
||||
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className='flex h-full flex-1 flex-col overflow-hidden'>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<WorkflowTour />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3909,7 +3909,11 @@ const WorkflowContent = React.memo(
|
||||
return (
|
||||
<div className='flex h-full w-full overflow-hidden'>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div ref={canvasContainerRef} className='relative flex-1 overflow-hidden'>
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
className='relative flex-1 overflow-hidden'
|
||||
data-tour='canvas'
|
||||
>
|
||||
{!isWorkflowReady && (
|
||||
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
|
||||
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
|
||||
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
|
||||
@@ -78,7 +79,14 @@ export const WorkflowList = memo(function WorkflowList({
|
||||
}: WorkflowListProps) {
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
const folders = useFolderStore((state) => state.folders)
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore(
|
||||
useShallow((s) => ({
|
||||
getFolderTree: s.getFolderTree,
|
||||
expandedFolders: s.expandedFolders,
|
||||
getFolderPath: s.getFolderPath,
|
||||
setExpanded: s.setExpanded,
|
||||
}))
|
||||
)
|
||||
|
||||
const {
|
||||
isOpen: isEmptyAreaMenuOpen,
|
||||
|
||||
@@ -47,7 +47,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
const reorderWorkflowsMutation = useReorderWorkflows()
|
||||
const reorderFoldersMutation = useReorderFolders()
|
||||
const { setExpanded, expandedFolders } = useFolderStore()
|
||||
const setExpanded = useFolderStore((s) => s.setExpanded)
|
||||
const expandedFolders = useFolderStore((s) => s.expandedFolders)
|
||||
|
||||
const handleAutoScroll = useCallback(() => {
|
||||
if (!scrollContainerRef.current) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
interface UseFolderSelectionProps {
|
||||
@@ -44,7 +45,15 @@ export function useFolderSelection({
|
||||
selectFolderOnly,
|
||||
selectFolderRange,
|
||||
toggleFolderSelection,
|
||||
} = useFolderStore()
|
||||
} = useFolderStore(
|
||||
useShallow((s) => ({
|
||||
selectedFolders: s.selectedFolders,
|
||||
lastSelectedFolderId: s.lastSelectedFolderId,
|
||||
selectFolderOnly: s.selectFolderOnly,
|
||||
selectFolderRange: s.selectFolderRange,
|
||||
toggleFolderSelection: s.toggleFolderSelection,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* Deselect any workflows whose folder (or any ancestor folder) is currently selected.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
@@ -10,7 +11,13 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore()
|
||||
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore(
|
||||
useShallow((s) => ({
|
||||
setSidebarWidth: s.setSidebarWidth,
|
||||
isResizing: s.isResizing,
|
||||
setIsResizing: s.setIsResizing,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
interface UseWorkflowSelectionProps {
|
||||
@@ -30,7 +31,14 @@ export function useWorkflowSelection({
|
||||
activeWorkflowId,
|
||||
workflowAncestorFolderIds,
|
||||
}: UseWorkflowSelectionProps) {
|
||||
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore()
|
||||
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore(
|
||||
useShallow((s) => ({
|
||||
selectedWorkflows: s.selectedWorkflows,
|
||||
selectOnly: s.selectOnly,
|
||||
selectRange: s.selectRange,
|
||||
toggleWorkflowSelection: s.toggleWorkflowSelection,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* After a workflow selection change, deselect any folder that is an ancestor of a selected
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import { Compass, MoreHorizontal } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Database,
|
||||
File,
|
||||
@@ -36,6 +37,10 @@ import {
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
START_NAV_TOUR_EVENT,
|
||||
START_WORKFLOW_TOUR_EVENT,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
@@ -210,6 +215,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
<Link
|
||||
href={item.href}
|
||||
data-item-id={item.id}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onClick={
|
||||
item.onClick
|
||||
@@ -228,6 +234,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
<button
|
||||
type='button'
|
||||
data-item-id={item.id}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
@@ -596,12 +603,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
const footerItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'help',
|
||||
label: 'Help',
|
||||
icon: HelpCircle,
|
||||
onClick: () => setIsHelpModalOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
@@ -618,6 +619,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[workspaceId, navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth]
|
||||
)
|
||||
|
||||
const handleStartTour = useCallback(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(isOnWorkflowPage ? START_WORKFLOW_TOUR_EVENT : START_NAV_TOUR_EVENT)
|
||||
)
|
||||
}, [isOnWorkflowPage])
|
||||
|
||||
const { data: fetchedTasks = [], isLoading: tasksLoading } = useTasks(workspaceId)
|
||||
|
||||
useTaskEvents(workspaceId)
|
||||
@@ -1134,7 +1141,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
)}
|
||||
>
|
||||
{/* Tasks */}
|
||||
<div className='flex flex-shrink-0 flex-col'>
|
||||
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
{!isCollapsed && (
|
||||
@@ -1248,7 +1255,10 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Workflows */}
|
||||
<div className='workflows-section relative mt-[14px] flex flex-col'>
|
||||
<div
|
||||
className='workflows-section relative mt-[14px] flex flex-col'
|
||||
data-tour='nav-workflows'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
|
||||
{!isCollapsed && (
|
||||
@@ -1390,6 +1400,49 @@ export const Sidebar = memo(function Sidebar() {
|
||||
!hasOverflowBottom && 'border-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Help dropdown */}
|
||||
<DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
data-item-id='help'
|
||||
className='group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
|
||||
Help
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenuTrigger>
|
||||
{showCollapsedContent && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Help</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent align='start' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
Docs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setIsHelpModalOpen(true)}>
|
||||
<HelpCircle className='h-[14px] w-[14px]' />
|
||||
Report an issue
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleStartTour}>
|
||||
<Compass className='h-[14px] w-[14px]' />
|
||||
Take a tour
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{footerItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
|
||||
@@ -36,8 +36,8 @@ interface UseCanDeleteReturn {
|
||||
* @returns Functions to check deletion eligibility
|
||||
*/
|
||||
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const { folders } = useFolderStore()
|
||||
const workflows = useWorkflowRegistry((s) => s.workflows)
|
||||
const folders = useFolderStore((s) => s.folders)
|
||||
|
||||
/**
|
||||
* Pre-computed data structures for efficient lookups
|
||||
|
||||
@@ -46,7 +46,8 @@ export function useDeleteSelection({
|
||||
onSuccess,
|
||||
}: UseDeleteSelectionProps) {
|
||||
const router = useRouter()
|
||||
const { workflows, removeWorkflow } = useWorkflowRegistry()
|
||||
const workflows = useWorkflowRegistry((s) => s.workflows)
|
||||
const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow)
|
||||
const deleteFolderMutation = useDeleteFolderMutation()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ export function useDeleteWorkflow({
|
||||
}: UseDeleteWorkflowProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { workflows, removeWorkflow } = useWorkflowRegistry()
|
||||
const workflows = useWorkflowRegistry((s) => s.workflows)
|
||||
const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,8 +89,8 @@ function collectSubfolders(
|
||||
* Hook for managing folder export to ZIP.
|
||||
*/
|
||||
export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const { folders } = useFolderStore()
|
||||
const workflows = useWorkflowRegistry((s) => s.workflows)
|
||||
const folders = useFolderStore((s) => s.folders)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const hasWorkflows = useMemo(() => {
|
||||
|
||||
@@ -12,7 +12,8 @@ const logger = createLogger('WorkflowsPage')
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const router = useRouter()
|
||||
const { workflows, setActiveWorkflow } = useWorkflowRegistry()
|
||||
const workflows = useWorkflowRegistry((s) => s.workflows)
|
||||
const setActiveWorkflow = useWorkflowRegistry((s) => s.setActiveWorkflow)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
|
||||
@@ -197,8 +197,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
initializedRef.current = true
|
||||
setIsConnecting(true)
|
||||
|
||||
const initializeSocket = () => {
|
||||
const initializeSocket = async () => {
|
||||
try {
|
||||
const { io } = await import('socket.io-client')
|
||||
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || 'http://localhost:3002'
|
||||
|
||||
logger.info('Attempting to connect to Socket.IO server', {
|
||||
|
||||
75
apps/sim/components/emcn/components/banner/banner.tsx
Normal file
75
apps/sim/components/emcn/components/banner/banner.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Button, type ButtonProps } from '@/components/emcn/components/button/button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const bannerVariants = cva('shrink-0 px-[24px] py-[10px]', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[var(--surface-active)]',
|
||||
destructive: 'bg-red-50 dark:bg-red-950/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
export interface BannerProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof bannerVariants> {
|
||||
actionClassName?: string
|
||||
actionDisabled?: boolean
|
||||
actionLabel?: ReactNode
|
||||
actionProps?: Omit<ButtonProps, 'children' | 'className' | 'disabled' | 'onClick' | 'variant'>
|
||||
actionVariant?: ButtonProps['variant']
|
||||
children?: ReactNode
|
||||
contentClassName?: string
|
||||
onAction?: () => void
|
||||
text?: ReactNode
|
||||
textClassName?: string
|
||||
}
|
||||
|
||||
export function Banner({
|
||||
actionClassName,
|
||||
actionDisabled,
|
||||
actionLabel,
|
||||
actionProps,
|
||||
actionVariant = 'default',
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
onAction,
|
||||
text,
|
||||
textClassName,
|
||||
variant,
|
||||
...props
|
||||
}: BannerProps) {
|
||||
return (
|
||||
<div className={cn(bannerVariants({ variant }), className)} {...props}>
|
||||
{children ?? (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex max-w-[1400px] items-center justify-between gap-[12px]',
|
||||
contentClassName
|
||||
)}
|
||||
>
|
||||
<p className={cn('text-[13px]', textClassName)}>{text}</p>
|
||||
{actionLabel ? (
|
||||
<Button
|
||||
variant={actionVariant}
|
||||
className={cn('h-[28px] shrink-0 px-[8px] text-[12px]', actionClassName)}
|
||||
onClick={onAction}
|
||||
disabled={actionDisabled}
|
||||
{...actionProps}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
avatarVariants,
|
||||
} from './avatar/avatar'
|
||||
export { Badge } from './badge/badge'
|
||||
export { Banner, type BannerProps } from './banner/banner'
|
||||
export { Breadcrumb, type BreadcrumbItem, type BreadcrumbProps } from './breadcrumb/breadcrumb'
|
||||
export { Button, type ButtonProps, buttonVariants } from './button/button'
|
||||
export {
|
||||
@@ -147,3 +148,10 @@ export { TimePicker, type TimePickerProps, timePickerVariants } from './time-pic
|
||||
export { CountdownRing } from './toast/countdown-ring'
|
||||
export { ToastProvider, toast, useToast } from './toast/toast'
|
||||
export { Tooltip } from './tooltip/tooltip'
|
||||
export {
|
||||
TourCard,
|
||||
type TourCardProps,
|
||||
TourTooltip,
|
||||
type TourTooltipPlacement,
|
||||
type TourTooltipProps,
|
||||
} from './tour-tooltip/tour-tooltip'
|
||||
|
||||
@@ -414,6 +414,18 @@ export interface PopoverContentProps
|
||||
* @default true
|
||||
*/
|
||||
avoidCollisions?: boolean
|
||||
/**
|
||||
* Show an arrow pointing toward the anchor element.
|
||||
* The arrow color matches the popover background based on the current color scheme.
|
||||
* @default false
|
||||
*/
|
||||
showArrow?: boolean
|
||||
/**
|
||||
* Custom className for the arrow element.
|
||||
* Overrides the default color-scheme-based fill when provided.
|
||||
* Useful when the popover background is overridden via className.
|
||||
*/
|
||||
arrowClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,6 +450,8 @@ const PopoverContent = React.forwardRef<
|
||||
collisionPadding = 8,
|
||||
border = false,
|
||||
avoidCollisions = true,
|
||||
showArrow = false,
|
||||
arrowClassName,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
...restProps
|
||||
@@ -592,10 +606,12 @@ const PopoverContent = React.forwardRef<
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'z-[10000200] flex flex-col overflow-auto outline-none will-change-transform',
|
||||
'z-[10000200] flex flex-col outline-none will-change-transform',
|
||||
showArrow ? 'overflow-visible' : 'overflow-auto',
|
||||
STYLES.colorScheme[colorScheme].content,
|
||||
STYLES.content,
|
||||
hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate',
|
||||
hasUserWidthConstraint &&
|
||||
'[&_.flex-1:not([data-popover-scroll])]:truncate [&_[data-popover-section]]:truncate',
|
||||
border && 'border border-[var(--border-1)]',
|
||||
className
|
||||
)}
|
||||
@@ -613,7 +629,34 @@ const PopoverContent = React.forwardRef<
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{showArrow ? (
|
||||
<div data-popover-scroll className='min-h-0 flex-1 overflow-auto'>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{showArrow && (
|
||||
<PopoverPrimitive.Arrow width={14} height={7} asChild>
|
||||
<svg
|
||||
width={14}
|
||||
height={7}
|
||||
viewBox='0 0 14 7'
|
||||
preserveAspectRatio='none'
|
||||
className={
|
||||
arrowClassName ??
|
||||
cn(
|
||||
colorScheme === 'inverted'
|
||||
? 'fill-[#242424] stroke-[#363636] dark:fill-[var(--surface-3)] dark:stroke-[var(--border-1)]'
|
||||
: 'fill-[var(--surface-3)] stroke-[var(--border-1)] dark:fill-[var(--surface-3)]'
|
||||
)
|
||||
}
|
||||
>
|
||||
<polygon points='0,0 14,0 7,7' className='stroke-none' />
|
||||
<polyline points='0,0 7,7 14,0' fill='none' strokeWidth={1} />
|
||||
</svg>
|
||||
</PopoverPrimitive.Arrow>
|
||||
)}
|
||||
</PopoverPrimitive.Content>
|
||||
)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const Content = React.forwardRef<
|
||||
collisionPadding={8}
|
||||
avoidCollisions={true}
|
||||
className={cn(
|
||||
'z-[10000300] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
|
||||
'z-[10000300] max-w-[260px] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -89,10 +89,75 @@ const Shortcut = ({ keys, className, children }: ShortcutProps) => (
|
||||
)
|
||||
Shortcut.displayName = 'Tooltip.Shortcut'
|
||||
|
||||
interface PreviewProps {
|
||||
/** The URL of the image, GIF, or video to display */
|
||||
src: string
|
||||
/** Alt text for the media */
|
||||
alt?: string
|
||||
/** Width of the preview in pixels */
|
||||
width?: number
|
||||
/** Height of the preview in pixels */
|
||||
height?: number
|
||||
/** Whether video should loop */
|
||||
loop?: boolean
|
||||
/** Optional additional class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov'] as const
|
||||
|
||||
/**
|
||||
* Displays a preview image, GIF, or video within tooltip content.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Tooltip.Content>
|
||||
* <p>Canvas error notifications</p>
|
||||
* <Tooltip.Preview src="/tooltips/canvas-error-notification.mp4" alt="Error notification example" />
|
||||
* </Tooltip.Content>
|
||||
* ```
|
||||
*/
|
||||
const Preview = ({ src, alt = '', width = 240, height, loop = true, className }: PreviewProps) => {
|
||||
const pathname = src.toLowerCase().split('?')[0].split('#')[0]
|
||||
const isVideo = VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('-mx-[8px] -mb-[3.5px] mt-[4px] overflow-hidden rounded-b-[4px]', className)}
|
||||
>
|
||||
{isVideo ? (
|
||||
<video
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
className='block w-full'
|
||||
autoPlay
|
||||
loop={loop}
|
||||
muted
|
||||
playsInline
|
||||
preload='none'
|
||||
aria-label={alt}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className='block w-full'
|
||||
loading='lazy'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Preview.displayName = 'Tooltip.Preview'
|
||||
|
||||
export const Tooltip = {
|
||||
Root,
|
||||
Trigger,
|
||||
Content,
|
||||
Provider,
|
||||
Shortcut,
|
||||
Preview,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client'
|
||||
|
||||
import type * as React from 'react'
|
||||
import { type RefObject, useRef } from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
type TourTooltipPlacement = 'top' | 'right' | 'bottom' | 'left' | 'center'
|
||||
|
||||
interface TourCardProps {
|
||||
/** Title displayed in the card header */
|
||||
title: string
|
||||
/** Description text in the card body */
|
||||
description: React.ReactNode
|
||||
/** Current step number (1-based) */
|
||||
step: number
|
||||
/** Total number of steps in the tour */
|
||||
totalSteps: number
|
||||
/** Whether this is the first step (hides Back button) */
|
||||
isFirst?: boolean
|
||||
/** Whether this is the last step (changes Next to Done) */
|
||||
isLast?: boolean
|
||||
/** Called when the user clicks Next or Done */
|
||||
onNext?: () => void
|
||||
/** Called when the user clicks Back */
|
||||
onBack?: () => void
|
||||
/** Called when the user dismisses the tour */
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
function TourCard({
|
||||
title,
|
||||
description,
|
||||
step,
|
||||
totalSteps,
|
||||
isFirst,
|
||||
isLast,
|
||||
onNext,
|
||||
onBack,
|
||||
onClose,
|
||||
}: TourCardProps) {
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between gap-2 px-4 pt-4 pb-2'>
|
||||
<h3 className='min-w-0 font-medium text-[var(--text-primary)] text-sm leading-none'>
|
||||
{title}
|
||||
</h3>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[16px] w-[16px] flex-shrink-0 p-0'
|
||||
onClick={onClose}
|
||||
aria-label='Close tour'
|
||||
>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='px-4 pt-1 pb-3'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)] leading-[1.6]'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-t px-4 py-3'>
|
||||
<span className='text-[11px] text-[var(--text-muted)] [font-variant-numeric:tabular-nums]'>
|
||||
{step} / {totalSteps}
|
||||
</span>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className={cn(isFirst && 'invisible')}>
|
||||
<Button
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={onBack}
|
||||
tabIndex={isFirst ? -1 : undefined}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant='tertiary' size='sm' onClick={onNext}>
|
||||
{isLast ? 'Done' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface TourTooltipProps extends TourCardProps {
|
||||
/** Placement relative to the target element */
|
||||
placement?: TourTooltipPlacement
|
||||
/** Target DOM element to anchor the tooltip to */
|
||||
targetEl: HTMLElement | null
|
||||
/** Controls tooltip visibility for smooth transitions */
|
||||
isVisible?: boolean
|
||||
/** Whether this is the initial entrance (plays full entrance animation) */
|
||||
isEntrance?: boolean
|
||||
/** Additional class names for the tooltip card */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PLACEMENT_TO_SIDE: Record<
|
||||
Exclude<TourTooltipPlacement, 'center'>,
|
||||
'top' | 'right' | 'bottom' | 'left'
|
||||
> = {
|
||||
top: 'top',
|
||||
right: 'right',
|
||||
bottom: 'bottom',
|
||||
left: 'left',
|
||||
}
|
||||
|
||||
/**
|
||||
* A positioned tooltip component for guided product tours.
|
||||
*
|
||||
* Anchors to a target DOM element using Radix Popover primitives for
|
||||
* collision-aware positioning. Supports centered placement for overlay steps.
|
||||
* The card surface matches the emcn Modal / DropdownMenu conventions.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TourTooltip
|
||||
* title="Welcome"
|
||||
* description="This is your dashboard."
|
||||
* step={1}
|
||||
* totalSteps={5}
|
||||
* placement="bottom"
|
||||
* targetEl={document.querySelector('[data-tour="home"]')}
|
||||
* onNext={handleNext}
|
||||
* onClose={handleClose}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function TourTooltip({
|
||||
title,
|
||||
description,
|
||||
step,
|
||||
totalSteps,
|
||||
placement = 'bottom',
|
||||
targetEl,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
isVisible = true,
|
||||
isEntrance = false,
|
||||
onNext,
|
||||
onBack,
|
||||
onClose,
|
||||
className,
|
||||
}: TourTooltipProps) {
|
||||
const virtualRef = useRef<HTMLElement | null>(null)
|
||||
virtualRef.current = targetEl
|
||||
|
||||
if (typeof document === 'undefined') return null
|
||||
if (!isVisible) return null
|
||||
|
||||
const isCentered = placement === 'center'
|
||||
|
||||
const cardClasses = cn(
|
||||
'w-[260px] overflow-hidden rounded-[8px] bg-[var(--bg)]',
|
||||
isEntrance && 'animate-tour-tooltip-in motion-reduce:animate-none',
|
||||
className
|
||||
)
|
||||
|
||||
const cardContent = (
|
||||
<TourCard
|
||||
title={title}
|
||||
description={description}
|
||||
step={step}
|
||||
totalSteps={totalSteps}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isCentered) {
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 z-[10000300] flex items-center justify-center'>
|
||||
<div
|
||||
className={cn(
|
||||
cardClasses,
|
||||
'pointer-events-auto relative border border-[var(--border)] shadow-sm'
|
||||
)}
|
||||
>
|
||||
{cardContent}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
if (!targetEl) return null
|
||||
|
||||
return createPortal(
|
||||
<PopoverPrimitive.Root open>
|
||||
<PopoverPrimitive.Anchor virtualRef={virtualRef as RefObject<HTMLElement>} />
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
side={PLACEMENT_TO_SIDE[placement] || 'bottom'}
|
||||
sideOffset={10}
|
||||
collisionPadding={12}
|
||||
avoidCollisions
|
||||
className='z-[10000300] outline-none'
|
||||
style={{
|
||||
filter: 'drop-shadow(0 0 0.5px var(--border)) drop-shadow(0 1px 2px rgba(0,0,0,0.1))',
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={cardClasses}>{cardContent}</div>
|
||||
<PopoverPrimitive.Arrow width={14} height={7} asChild>
|
||||
<svg
|
||||
width={14}
|
||||
height={7}
|
||||
viewBox='0 0 14 7'
|
||||
preserveAspectRatio='none'
|
||||
className='-mt-px fill-[var(--bg)] stroke-[var(--border)]'
|
||||
>
|
||||
<polygon points='0,0 14,0 7,7' className='stroke-none' />
|
||||
<polyline points='0,0 7,7 14,0' fill='none' strokeWidth={1} />
|
||||
</svg>
|
||||
</PopoverPrimitive.Arrow>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export { TourCard, TourTooltip }
|
||||
export type { TourCardProps, TourTooltipPlacement, TourTooltipProps }
|
||||
@@ -36,10 +36,13 @@ export interface A2AAgent {
|
||||
*/
|
||||
export const a2aAgentKeys = {
|
||||
all: ['a2a-agents'] as const,
|
||||
list: (workspaceId: string) => [...a2aAgentKeys.all, 'list', workspaceId] as const,
|
||||
detail: (agentId: string) => [...a2aAgentKeys.all, 'detail', agentId] as const,
|
||||
lists: () => [...a2aAgentKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...a2aAgentKeys.lists(), workspaceId] as const,
|
||||
details: () => [...a2aAgentKeys.all, 'detail'] as const,
|
||||
detail: (agentId: string) => [...a2aAgentKeys.details(), agentId] as const,
|
||||
byWorkflows: () => [...a2aAgentKeys.all, 'byWorkflow'] as const,
|
||||
byWorkflow: (workspaceId: string, workflowId: string) =>
|
||||
[...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId] as const,
|
||||
[...a2aAgentKeys.byWorkflows(), workspaceId, workflowId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,9 +151,11 @@ export function useCreateA2AAgent() {
|
||||
return useMutation({
|
||||
mutationFn: createA2AAgent,
|
||||
onSuccess: () => {
|
||||
// Invalidate all a2a-agent queries (list, detail, byWorkflow, etc.)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.all,
|
||||
queryKey: a2aAgentKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.byWorkflows(),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -197,10 +202,15 @@ export function useUpdateA2AAgent() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateA2AAgent,
|
||||
onSuccess: () => {
|
||||
// Invalidate all a2a-agent queries (list, detail, byWorkflow, etc.)
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.all,
|
||||
queryKey: a2aAgentKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.detail(variables.agentId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.byWorkflows(),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -227,10 +237,15 @@ export function useDeleteA2AAgent() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteA2AAgent,
|
||||
onSuccess: () => {
|
||||
// Invalidate all a2a-agent queries (list, detail, byWorkflow, etc.)
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.all,
|
||||
queryKey: a2aAgentKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.detail(variables.agentId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.byWorkflows(),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -272,10 +287,15 @@ export function usePublishA2AAgent() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: publishA2AAgent,
|
||||
onSuccess: () => {
|
||||
// Invalidate all a2a-agent queries (list, detail, byWorkflow, etc.)
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.all,
|
||||
queryKey: a2aAgentKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.detail(variables.agentId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.byWorkflows(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -133,3 +133,27 @@ export function useUnbanUser() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useImpersonateUser() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId }: { userId: string }) => {
|
||||
const result = await client.admin.impersonateUser({ userId })
|
||||
return result
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Failed to impersonate user', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopImpersonating() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await client.admin.stopImpersonating()
|
||||
return result
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Failed to stop impersonating', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
|
||||
export interface CredentialSet {
|
||||
@@ -88,6 +88,7 @@ export function useCredentialSets(organizationId?: string, enabled = true) {
|
||||
queryFn: ({ signal }) => fetchCredentialSets(organizationId ?? '', signal),
|
||||
enabled: Boolean(organizationId) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,6 +113,7 @@ export function useCredentialSetDetail(id?: string, enabled = true) {
|
||||
queryFn: ({ signal }) => fetchCredentialSetById(id ?? '', signal),
|
||||
enabled: Boolean(id) && enabled,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,6 +318,9 @@ export function useDeleteCredentialSet() {
|
||||
queryKey: credentialSetKeys.list(variables.organizationId),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: credentialSetKeys.detail(variables.credentialSetId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -89,6 +89,7 @@ export function useDeploymentInfo(workflowId: string | null, options?: { enabled
|
||||
queryFn: ({ signal }) => fetchDeploymentInfo(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ export function useDeployedWorkflowState(
|
||||
queryFn: ({ signal }) => fetchDeployedWorkflowState(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,6 +164,7 @@ export function useDeploymentVersions(workflowId: string | null, options?: { ena
|
||||
queryFn: ({ signal }) => fetchDeploymentVersions(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,6 +212,7 @@ export function useChatDeploymentStatus(
|
||||
queryFn: ({ signal }) => fetchChatDeploymentStatus(workflowId!, signal),
|
||||
enabled: Boolean(workflowId) && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,6 +260,7 @@ export function useChatDetail(chatId: string | null, options?: { enabled?: boole
|
||||
queryFn: ({ signal }) => fetchChatDetail(chatId!, signal),
|
||||
enabled: Boolean(chatId) && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -731,7 +731,7 @@ export function useCreateKnowledgeBase(workspaceId?: string) {
|
||||
mutationFn: createKnowledgeBase,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.all,
|
||||
queryKey: knowledgeKeys.lists(),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -779,10 +779,10 @@ export function useUpdateKnowledgeBase(workspaceId?: string) {
|
||||
},
|
||||
onSuccess: (_, { knowledgeBaseId }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
queryKey: knowledgeKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.all,
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -815,9 +815,12 @@ export function useDeleteKnowledgeBase(workspaceId?: string) {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteKnowledgeBase,
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.all,
|
||||
queryKey: knowledgeKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(variables.knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
const logger = createLogger('NotificationQueries')
|
||||
@@ -97,6 +97,7 @@ export function useNotifications(workspaceId?: string) {
|
||||
queryFn: ({ signal }) => fetchNotifications(workspaceId!, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
|
||||
@@ -96,6 +96,7 @@ export function useWorkspaceSchedules(workspaceId?: string) {
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ export function useScheduleQuery(
|
||||
enabled: !!workflowId && !!blockId && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
retry: false,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,6 +236,7 @@ export function useDisableSchedule() {
|
||||
},
|
||||
onSuccess: ({ workspaceId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to disable schedule', { error })
|
||||
@@ -268,6 +271,7 @@ export function useDeleteSchedule() {
|
||||
},
|
||||
onSuccess: ({ workspaceId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to delete schedule', { error })
|
||||
@@ -311,6 +315,7 @@ export function useUpdateSchedule() {
|
||||
},
|
||||
onSuccess: ({ workspaceId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update schedule', { error })
|
||||
|
||||
@@ -79,7 +79,8 @@ export const taskKeys = {
|
||||
all: ['tasks'] as const,
|
||||
lists: () => [...taskKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined) => [...taskKeys.lists(), workspaceId ?? ''] as const,
|
||||
detail: (chatId: string | undefined) => [...taskKeys.all, 'detail', chatId ?? ''] as const,
|
||||
details: () => [...taskKeys.all, 'detail'] as const,
|
||||
detail: (chatId: string | undefined) => [...taskKeys.details(), chatId ?? ''] as const,
|
||||
}
|
||||
|
||||
interface TaskResponse {
|
||||
@@ -177,8 +178,9 @@ export function useDeleteTask(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: deleteTask,
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _error, chatId) => {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
queryClient.removeQueries({ queryKey: taskKeys.detail(chatId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -192,8 +194,11 @@ export function useDeleteTasks(workspaceId?: string) {
|
||||
mutationFn: async (chatIds: string[]) => {
|
||||
await Promise.all(chatIds.map(deleteTask))
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _error, chatIds) => {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
for (const chatId of chatIds) {
|
||||
queryClient.removeQueries({ queryKey: taskKeys.detail(chatId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -232,8 +237,9 @@ export function useRenameTask(workspaceId?: string) {
|
||||
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.detail(variables.chatId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export function useWorkflowState(workflowId: string | undefined) {
|
||||
queryFn: ({ signal }) => fetchWorkflowState(workflowId!, signal),
|
||||
enabled: Boolean(workflowId),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -120,8 +120,9 @@ export function useDeleteWorkspace() {
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cache } from 'react'
|
||||
import { sso } from '@better-auth/sso'
|
||||
import { stripe } from '@better-auth/stripe'
|
||||
import { db } from '@sim/db'
|
||||
@@ -716,7 +717,7 @@ export const auth = betterAuth({
|
||||
captcha({
|
||||
provider: 'cloudflare-turnstile',
|
||||
secretKey: env.TURNSTILE_SECRET_KEY,
|
||||
endpoints: ['/sign-up/email', '/sign-in/email'],
|
||||
endpoints: ['/sign-up/email'],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
@@ -3023,7 +3024,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
})
|
||||
|
||||
export async function getSession() {
|
||||
async function getSessionImpl() {
|
||||
if (isAuthDisabled) {
|
||||
await ensureAnonymousUserExists()
|
||||
return createAnonymousSession()
|
||||
@@ -3035,5 +3036,7 @@ export async function getSession() {
|
||||
})
|
||||
}
|
||||
|
||||
export const getSession = cache(getSessionImpl)
|
||||
|
||||
export const signIn = auth.api.signInEmail
|
||||
export const signUp = auth.api.signUpEmail
|
||||
|
||||
@@ -13,7 +13,6 @@ export const mdxComponents: MDXRemoteProps['components'] = {
|
||||
className={clsx('h-auto w-full rounded-lg', props.className)}
|
||||
sizes='(max-width: 768px) 100vw, 800px'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
),
|
||||
h2: ({ children, className, ...props }: any) => (
|
||||
|
||||
@@ -86,6 +86,20 @@ export async function getLatestRunForExecution(executionId: string) {
|
||||
return run ?? null
|
||||
}
|
||||
|
||||
export async function getLatestRunForStream(streamId: string, userId?: string) {
|
||||
const conditions = userId
|
||||
? and(eq(copilotRuns.streamId, streamId), eq(copilotRuns.userId, userId))
|
||||
: eq(copilotRuns.streamId, streamId)
|
||||
const [run] = await db
|
||||
.select()
|
||||
.from(copilotRuns)
|
||||
.where(conditions)
|
||||
.orderBy(desc(copilotRuns.startedAt))
|
||||
.limit(1)
|
||||
|
||||
return run ?? null
|
||||
}
|
||||
|
||||
export async function getRunSegment(runId: string) {
|
||||
const [run] = await db.select().from(copilotRuns).where(eq(copilotRuns.id, runId)).limit(1)
|
||||
return run ?? null
|
||||
@@ -121,6 +135,20 @@ export async function upsertAsyncToolCall(input: {
|
||||
status?: CopilotAsyncToolStatus
|
||||
}) {
|
||||
const existing = await getAsyncToolCall(input.toolCallId)
|
||||
const incomingStatus = input.status ?? 'pending'
|
||||
if (
|
||||
existing &&
|
||||
(isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) &&
|
||||
!isTerminalAsyncStatus(incomingStatus) &&
|
||||
!isDeliveredAsyncStatus(incomingStatus)
|
||||
) {
|
||||
logger.info('Ignoring async tool upsert that would downgrade terminal state', {
|
||||
toolCallId: input.toolCallId,
|
||||
existingStatus: existing.status,
|
||||
incomingStatus,
|
||||
})
|
||||
return existing
|
||||
}
|
||||
const effectiveRunId = input.runId ?? existing?.runId ?? null
|
||||
if (!effectiveRunId) {
|
||||
logger.warn('upsertAsyncToolCall missing runId and no existing row', {
|
||||
@@ -140,7 +168,7 @@ export async function upsertAsyncToolCall(input: {
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
args: input.args ?? {},
|
||||
status: input.status ?? 'pending',
|
||||
status: incomingStatus,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
@@ -150,7 +178,7 @@ export async function upsertAsyncToolCall(input: {
|
||||
checkpointId: input.checkpointId ?? null,
|
||||
toolName: input.toolName,
|
||||
args: input.args ?? {},
|
||||
status: input.status ?? 'pending',
|
||||
status: incomingStatus,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,14 +8,19 @@ import type { OrchestrateStreamOptions } from '@/lib/copilot/orchestrator'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import {
|
||||
createStreamEventWriter,
|
||||
getStreamMeta,
|
||||
resetStreamBuffer,
|
||||
setStreamMeta,
|
||||
} from '@/lib/copilot/orchestrator/stream/buffer'
|
||||
import { taskPubSub } from '@/lib/copilot/task-events'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
|
||||
const logger = createLogger('CopilotChatStreaming')
|
||||
const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60
|
||||
const STREAM_ABORT_TTL_SECONDS = 10 * 60
|
||||
const STREAM_ABORT_POLL_MS = 1000
|
||||
|
||||
// Registry of in-flight Sim→Go streams so the explicit abort endpoint can
|
||||
// reach them. Keyed by streamId, cleaned up when the stream completes.
|
||||
@@ -48,25 +53,138 @@ function resolvePendingChatStream(chatId: string, streamId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort any in-flight stream on `chatId` and wait for it to fully settle
|
||||
* (including onComplete and Go-side persistence). Returns immediately if
|
||||
* no stream is active. Gives up after `timeoutMs`.
|
||||
*/
|
||||
export async function waitForPendingChatStream(chatId: string, timeoutMs = 5_000): Promise<void> {
|
||||
const entry = pendingChatStreams.get(chatId)
|
||||
if (!entry) return
|
||||
|
||||
// Force-abort the previous stream so we don't passively wait for it to
|
||||
// finish naturally (which could take tens of seconds for a subagent).
|
||||
abortActiveStream(entry.streamId)
|
||||
|
||||
await Promise.race([entry.promise, new Promise<void>((r) => setTimeout(r, timeoutMs))])
|
||||
function getChatStreamLockKey(chatId: string): string {
|
||||
return `copilot:chat-stream-lock:${chatId}`
|
||||
}
|
||||
|
||||
export function abortActiveStream(streamId: string): boolean {
|
||||
function getStreamAbortKey(streamId: string): string {
|
||||
return `copilot:stream-abort:${streamId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for any in-flight stream on `chatId` to settle without force-aborting it.
|
||||
* Returns true when no stream is active (or it settles in time), false on timeout.
|
||||
*/
|
||||
export async function waitForPendingChatStream(
|
||||
chatId: string,
|
||||
timeoutMs = 5_000,
|
||||
expectedStreamId?: string
|
||||
): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
for (;;) {
|
||||
const entry = pendingChatStreams.get(chatId)
|
||||
const localPending = !!entry && (!expectedStreamId || entry.streamId === expectedStreamId)
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
|
||||
const lockReleased =
|
||||
!ownerStreamId || (expectedStreamId !== undefined && ownerStreamId !== expectedStreamId)
|
||||
if (!localPending && lockReleased) {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check distributed chat stream lock while waiting', {
|
||||
chatId,
|
||||
expectedStreamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
} else if (!localPending) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) return false
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
export async function releasePendingChatStream(chatId: string, streamId: string): Promise<void> {
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
await releaseLock(getChatStreamLockKey(chatId), streamId).catch(() => false)
|
||||
}
|
||||
resolvePendingChatStream(chatId, streamId)
|
||||
}
|
||||
|
||||
export async function acquirePendingChatStream(
|
||||
chatId: string,
|
||||
streamId: string,
|
||||
timeoutMs = 5_000
|
||||
): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
try {
|
||||
const acquired = await acquireLock(
|
||||
getChatStreamLockKey(chatId),
|
||||
streamId,
|
||||
CHAT_STREAM_LOCK_TTL_SECONDS
|
||||
)
|
||||
if (acquired) {
|
||||
registerPendingChatStream(chatId, streamId)
|
||||
return true
|
||||
}
|
||||
if (!pendingChatStreams.has(chatId)) {
|
||||
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
|
||||
if (ownerStreamId) {
|
||||
const ownerMeta = await getStreamMeta(ownerStreamId)
|
||||
const ownerTerminal =
|
||||
ownerMeta?.status === 'complete' ||
|
||||
ownerMeta?.status === 'error' ||
|
||||
ownerMeta?.status === 'cancelled'
|
||||
if (ownerTerminal) {
|
||||
await releaseLock(getChatStreamLockKey(chatId), ownerStreamId).catch(() => false)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Distributed chat stream lock failed; retrying distributed coordination', {
|
||||
chatId,
|
||||
streamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
if (Date.now() >= deadline) return false
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const existing = pendingChatStreams.get(chatId)
|
||||
if (!existing) {
|
||||
registerPendingChatStream(chatId, streamId)
|
||||
return true
|
||||
}
|
||||
|
||||
const settled = await Promise.race([
|
||||
existing.promise.then(() => true),
|
||||
new Promise<boolean>((r) => setTimeout(() => r(false), timeoutMs)),
|
||||
])
|
||||
if (!settled) return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortActiveStream(streamId: string): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
let published = false
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.set(getStreamAbortKey(streamId), '1', 'EX', STREAM_ABORT_TTL_SECONDS)
|
||||
published = true
|
||||
} catch (error) {
|
||||
logger.warn('Failed to publish distributed stream abort', {
|
||||
streamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
const controller = activeStreams.get(streamId)
|
||||
if (!controller) return false
|
||||
if (!controller) return published
|
||||
controller.abort()
|
||||
activeStreams.delete(streamId)
|
||||
return true
|
||||
@@ -135,6 +253,7 @@ export interface StreamingOrchestrationParams {
|
||||
requestId: string
|
||||
workspaceId?: string
|
||||
orchestrateOptions: Omit<OrchestrateStreamOptions, 'onEvent'>
|
||||
pendingChatStreamAlreadyRegistered?: boolean
|
||||
}
|
||||
|
||||
export function createSSEStream(params: StreamingOrchestrationParams): ReadableStream {
|
||||
@@ -153,6 +272,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
requestId,
|
||||
workspaceId,
|
||||
orchestrateOptions,
|
||||
pendingChatStreamAlreadyRegistered = false,
|
||||
} = params
|
||||
|
||||
let eventWriter: ReturnType<typeof createStreamEventWriter> | null = null
|
||||
@@ -160,7 +280,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
const abortController = new AbortController()
|
||||
activeStreams.set(streamId, abortController)
|
||||
|
||||
if (chatId) {
|
||||
if (chatId && !pendingChatStreamAlreadyRegistered) {
|
||||
registerPendingChatStream(chatId, streamId)
|
||||
}
|
||||
|
||||
@@ -191,14 +311,47 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
eventWriter = createStreamEventWriter(streamId)
|
||||
|
||||
let localSeq = 0
|
||||
let abortPoller: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
abortPoller = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const shouldAbort = await redis.get(getStreamAbortKey(streamId))
|
||||
if (shouldAbort && !abortController.signal.aborted) {
|
||||
abortController.abort()
|
||||
await redis.del(getStreamAbortKey(streamId))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Failed to poll distributed stream abort`, {
|
||||
streamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
})()
|
||||
}, STREAM_ABORT_POLL_MS)
|
||||
}
|
||||
|
||||
const pushEvent = async (event: Record<string, any>) => {
|
||||
if (!eventWriter) return
|
||||
|
||||
const eventId = ++localSeq
|
||||
|
||||
// Enqueue to client stream FIRST for minimal latency.
|
||||
// Redis persistence happens after so the client never waits on I/O.
|
||||
try {
|
||||
await eventWriter.write(event)
|
||||
if (FLUSH_EVENT_TYPES.has(event.type)) {
|
||||
await eventWriter.flush()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to persist stream event`, {
|
||||
eventType: event.type,
|
||||
eventId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
// Keep the live SSE stream going even if durable buffering hiccups.
|
||||
}
|
||||
|
||||
try {
|
||||
if (!clientDisconnected) {
|
||||
controller.enqueue(
|
||||
@@ -208,16 +361,16 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
} catch {
|
||||
clientDisconnected = true
|
||||
}
|
||||
}
|
||||
|
||||
const pushEventBestEffort = async (event: Record<string, any>) => {
|
||||
try {
|
||||
await eventWriter.write(event)
|
||||
if (FLUSH_EVENT_TYPES.has(event.type)) {
|
||||
await eventWriter.flush()
|
||||
}
|
||||
} catch {
|
||||
if (clientDisconnected) {
|
||||
await eventWriter.flush().catch(() => {})
|
||||
}
|
||||
await pushEvent(event)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to push event`, {
|
||||
eventType: event.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +437,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
logger.error(`[${requestId}] Orchestration returned failure`, {
|
||||
error: errorMessage,
|
||||
})
|
||||
await pushEvent({
|
||||
await pushEventBestEffort({
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
data: {
|
||||
@@ -324,7 +477,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
}
|
||||
logger.error(`[${requestId}] Orchestration error:`, error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Stream error'
|
||||
await pushEvent({
|
||||
await pushEventBestEffort({
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
data: {
|
||||
@@ -345,10 +498,19 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
|
||||
}).catch(() => {})
|
||||
} finally {
|
||||
clearInterval(keepaliveInterval)
|
||||
if (abortPoller) {
|
||||
clearInterval(abortPoller)
|
||||
}
|
||||
activeStreams.delete(streamId)
|
||||
if (chatId) {
|
||||
if (redis) {
|
||||
await releaseLock(getChatStreamLockKey(chatId), streamId).catch(() => false)
|
||||
}
|
||||
resolvePendingChatStream(chatId, streamId)
|
||||
}
|
||||
if (redis) {
|
||||
await redis.del(getStreamAbortKey(streamId)).catch(() => {})
|
||||
}
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrchestratorOptions } from './types'
|
||||
|
||||
const {
|
||||
prepareExecutionContext,
|
||||
getEffectiveDecryptedEnv,
|
||||
runStreamLoop,
|
||||
claimCompletedAsyncToolCall,
|
||||
getAsyncToolCall,
|
||||
getAsyncToolCalls,
|
||||
markAsyncToolDelivered,
|
||||
releaseCompletedAsyncToolClaim,
|
||||
updateRunStatus,
|
||||
} = vi.hoisted(() => ({
|
||||
prepareExecutionContext: vi.fn(),
|
||||
getEffectiveDecryptedEnv: vi.fn(),
|
||||
runStreamLoop: vi.fn(),
|
||||
claimCompletedAsyncToolCall: vi.fn(),
|
||||
getAsyncToolCall: vi.fn(),
|
||||
getAsyncToolCalls: vi.fn(),
|
||||
markAsyncToolDelivered: vi.fn(),
|
||||
releaseCompletedAsyncToolClaim: vi.fn(),
|
||||
updateRunStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/orchestrator/tool-executor', () => ({
|
||||
prepareExecutionContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/environment/utils', () => ({
|
||||
getEffectiveDecryptedEnv,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/async-runs/repository', () => ({
|
||||
claimCompletedAsyncToolCall,
|
||||
getAsyncToolCall,
|
||||
getAsyncToolCalls,
|
||||
markAsyncToolDelivered,
|
||||
releaseCompletedAsyncToolClaim,
|
||||
updateRunStatus,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/orchestrator/stream/core', async () => {
|
||||
const actual = await vi.importActual<typeof import('./stream/core')>('./stream/core')
|
||||
return {
|
||||
...actual,
|
||||
buildToolCallSummaries: vi.fn(() => []),
|
||||
runStreamLoop,
|
||||
}
|
||||
})
|
||||
|
||||
import { orchestrateCopilotStream } from './index'
|
||||
|
||||
describe('orchestrateCopilotStream async continuation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prepareExecutionContext.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
chatId: 'chat-1',
|
||||
})
|
||||
getEffectiveDecryptedEnv.mockResolvedValue({})
|
||||
claimCompletedAsyncToolCall.mockResolvedValue({ toolCallId: 'tool-1' })
|
||||
getAsyncToolCall.mockResolvedValue({
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read',
|
||||
status: 'completed',
|
||||
result: { ok: true },
|
||||
error: null,
|
||||
})
|
||||
getAsyncToolCalls.mockResolvedValue([
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read',
|
||||
status: 'completed',
|
||||
result: { ok: true },
|
||||
error: null,
|
||||
},
|
||||
])
|
||||
markAsyncToolDelivered.mockResolvedValue(null)
|
||||
releaseCompletedAsyncToolClaim.mockResolvedValue(null)
|
||||
updateRunStatus.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('builds resume payloads with success=true for claimed completed rows', async () => {
|
||||
runStreamLoop
|
||||
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
|
||||
context.awaitingAsyncContinuation = {
|
||||
checkpointId: 'checkpoint-1',
|
||||
runId: 'run-1',
|
||||
pendingToolCallIds: ['tool-1'],
|
||||
}
|
||||
})
|
||||
.mockImplementationOnce(async (url: string, opts: RequestInit) => {
|
||||
expect(url).toContain('/api/tools/resume')
|
||||
const body = JSON.parse(String(opts.body))
|
||||
expect(body).toEqual({
|
||||
checkpointId: 'checkpoint-1',
|
||||
results: [
|
||||
{
|
||||
callId: 'tool-1',
|
||||
name: 'read',
|
||||
data: { ok: true },
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
const result = await orchestrateCopilotStream(
|
||||
{ message: 'hello' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
chatId: 'chat-1',
|
||||
executionId: 'exec-1',
|
||||
runId: 'run-1',
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-1')
|
||||
})
|
||||
|
||||
it('marks claimed tool calls delivered even when the resumed stream later records errors', async () => {
|
||||
runStreamLoop
|
||||
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
|
||||
context.awaitingAsyncContinuation = {
|
||||
checkpointId: 'checkpoint-1',
|
||||
runId: 'run-1',
|
||||
pendingToolCallIds: ['tool-1'],
|
||||
}
|
||||
})
|
||||
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
|
||||
context.errors.push('resume stream failed after handoff')
|
||||
})
|
||||
|
||||
const result = await orchestrateCopilotStream(
|
||||
{ message: 'hello' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
chatId: 'chat-1',
|
||||
executionId: 'exec-1',
|
||||
runId: 'run-1',
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-1')
|
||||
})
|
||||
|
||||
it('forwards done events while still marking async pauses on the run', async () => {
|
||||
const onEvent = vi.fn()
|
||||
const streamOptions: OrchestratorOptions = { onEvent }
|
||||
runStreamLoop.mockImplementationOnce(
|
||||
async (_url: string, _opts: RequestInit, _context: any, _exec: any, loopOptions: any) => {
|
||||
await loopOptions.onEvent({
|
||||
type: 'done',
|
||||
data: {
|
||||
response: {
|
||||
async_pause: {
|
||||
checkpointId: 'checkpoint-1',
|
||||
runId: 'run-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await orchestrateCopilotStream(
|
||||
{ message: 'hello' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
chatId: 'chat-1',
|
||||
executionId: 'exec-1',
|
||||
runId: 'run-1',
|
||||
...streamOptions,
|
||||
}
|
||||
)
|
||||
|
||||
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'done' }))
|
||||
expect(updateRunStatus).toHaveBeenCalledWith('run-1', 'paused_waiting_for_tool')
|
||||
})
|
||||
|
||||
it('waits for a local running tool before retrying the claim', async () => {
|
||||
const localPendingPromise = Promise.resolve({
|
||||
status: 'success',
|
||||
data: { ok: true },
|
||||
})
|
||||
|
||||
claimCompletedAsyncToolCall
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ toolCallId: 'tool-1' })
|
||||
getAsyncToolCall
|
||||
.mockResolvedValueOnce({
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read',
|
||||
status: 'running',
|
||||
result: null,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValue({
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read',
|
||||
status: 'completed',
|
||||
result: { ok: true },
|
||||
error: null,
|
||||
})
|
||||
|
||||
runStreamLoop
|
||||
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
|
||||
context.awaitingAsyncContinuation = {
|
||||
checkpointId: 'checkpoint-1',
|
||||
runId: 'run-1',
|
||||
pendingToolCallIds: ['tool-1'],
|
||||
}
|
||||
context.pendingToolPromises.set('tool-1', localPendingPromise)
|
||||
})
|
||||
.mockImplementationOnce(async (url: string, opts: RequestInit) => {
|
||||
expect(url).toContain('/api/tools/resume')
|
||||
const body = JSON.parse(String(opts.body))
|
||||
expect(body.results[0]).toEqual({
|
||||
callId: 'tool-1',
|
||||
name: 'read',
|
||||
data: { ok: true },
|
||||
success: true,
|
||||
})
|
||||
})
|
||||
|
||||
const result = await orchestrateCopilotStream(
|
||||
{ message: 'hello' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
chatId: 'chat-1',
|
||||
executionId: 'exec-1',
|
||||
runId: 'run-1',
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(runStreamLoop).toHaveBeenCalledTimes(2)
|
||||
expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-1')
|
||||
})
|
||||
|
||||
it('releases claimed rows if the resume stream throws before delivery is marked', async () => {
|
||||
runStreamLoop
|
||||
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
|
||||
context.awaitingAsyncContinuation = {
|
||||
checkpointId: 'checkpoint-1',
|
||||
runId: 'run-1',
|
||||
pendingToolCallIds: ['tool-1'],
|
||||
}
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('resume failed')
|
||||
})
|
||||
|
||||
const result = await orchestrateCopilotStream(
|
||||
{ message: 'hello' },
|
||||
{
|
||||
userId: 'user-1',
|
||||
workflowId: 'workflow-1',
|
||||
chatId: 'chat-1',
|
||||
executionId: 'exec-1',
|
||||
runId: 'run-1',
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(releaseCompletedAsyncToolClaim).toHaveBeenCalledWith('tool-1', 'run-1')
|
||||
expect(markAsyncToolDelivered).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -14,12 +14,17 @@ import {
|
||||
updateRunStatus,
|
||||
} from '@/lib/copilot/async-runs/repository'
|
||||
import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
|
||||
import { prepareExecutionContext } from '@/lib/copilot/orchestrator/tool-executor'
|
||||
import type {
|
||||
ExecutionContext,
|
||||
OrchestratorOptions,
|
||||
OrchestratorResult,
|
||||
SSEEvent,
|
||||
import {
|
||||
isToolAvailableOnSimSide,
|
||||
prepareExecutionContext,
|
||||
} from '@/lib/copilot/orchestrator/tool-executor'
|
||||
import {
|
||||
type ExecutionContext,
|
||||
isTerminalToolCallStatus,
|
||||
type OrchestratorOptions,
|
||||
type OrchestratorResult,
|
||||
type SSEEvent,
|
||||
type ToolCallState,
|
||||
} from '@/lib/copilot/orchestrator/types'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
@@ -31,18 +36,9 @@ function didAsyncToolSucceed(input: {
|
||||
durableStatus?: string | null
|
||||
durableResult?: Record<string, unknown>
|
||||
durableError?: string | null
|
||||
completion?: { status: string } | undefined
|
||||
toolStateSuccess?: boolean | undefined
|
||||
toolStateStatus?: string | undefined
|
||||
}) {
|
||||
const {
|
||||
durableStatus,
|
||||
durableResult,
|
||||
durableError,
|
||||
completion,
|
||||
toolStateSuccess,
|
||||
toolStateStatus,
|
||||
} = input
|
||||
const { durableStatus, durableResult, durableError, toolStateStatus } = input
|
||||
|
||||
if (durableStatus === ASYNC_TOOL_STATUS.completed) {
|
||||
return true
|
||||
@@ -61,7 +57,15 @@ function didAsyncToolSucceed(input: {
|
||||
if (toolStateStatus === 'success') return true
|
||||
if (toolStateStatus === 'error' || toolStateStatus === 'cancelled') return false
|
||||
|
||||
return completion?.status === 'success' || toolStateSuccess === true
|
||||
return false
|
||||
}
|
||||
|
||||
interface ReadyContinuationTool {
|
||||
toolCallId: string
|
||||
toolState?: ToolCallState
|
||||
durableRow?: Awaited<ReturnType<typeof getAsyncToolCall>>
|
||||
needsDurableClaim: boolean
|
||||
alreadyClaimedByWorker: boolean
|
||||
}
|
||||
|
||||
export interface OrchestrateStreamOptions extends OrchestratorOptions {
|
||||
@@ -190,32 +194,21 @@ export async function orchestrateCopilotStream(
|
||||
if (!continuation) break
|
||||
|
||||
let resumeReady = false
|
||||
let resumeRetries = 0
|
||||
for (;;) {
|
||||
claimedToolCallIds = []
|
||||
claimedByWorkerId = null
|
||||
const resumeWorkerId = continuation.runId || context.runId || context.messageId
|
||||
claimedByWorkerId = resumeWorkerId
|
||||
const claimableToolCallIds: string[] = []
|
||||
const readyTools: ReadyContinuationTool[] = []
|
||||
const localPendingPromises: Promise<unknown>[] = []
|
||||
const missingToolCallIds: string[] = []
|
||||
|
||||
for (const toolCallId of continuation.pendingToolCallIds) {
|
||||
const claimed = await claimCompletedAsyncToolCall(toolCallId, resumeWorkerId).catch(
|
||||
() => null
|
||||
)
|
||||
if (claimed) {
|
||||
claimableToolCallIds.push(toolCallId)
|
||||
claimedToolCallIds.push(toolCallId)
|
||||
continue
|
||||
}
|
||||
const durableRow = await getAsyncToolCall(toolCallId).catch(() => null)
|
||||
const localPendingPromise = context.pendingToolPromises.get(toolCallId)
|
||||
if (!durableRow && localPendingPromise) {
|
||||
claimableToolCallIds.push(toolCallId)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
durableRow &&
|
||||
durableRow.status === ASYNC_TOOL_STATUS.running &&
|
||||
localPendingPromise
|
||||
) {
|
||||
const toolState = context.toolCalls.get(toolCallId)
|
||||
|
||||
if (localPendingPromise) {
|
||||
localPendingPromises.push(localPendingPromise)
|
||||
logger.info('Waiting for local async tool completion before retrying resume claim', {
|
||||
toolCallId,
|
||||
@@ -223,21 +216,55 @@ export async function orchestrateCopilotStream(
|
||||
})
|
||||
continue
|
||||
}
|
||||
const toolState = context.toolCalls.get(toolCallId)
|
||||
if (!durableRow && !localPendingPromise && toolState) {
|
||||
|
||||
if (durableRow && isTerminalAsyncStatus(durableRow.status)) {
|
||||
if (durableRow.claimedBy && durableRow.claimedBy !== resumeWorkerId) {
|
||||
missingToolCallIds.push(toolCallId)
|
||||
logger.warn('Async tool continuation is waiting on a claim held by another worker', {
|
||||
toolCallId,
|
||||
runId: continuation.runId,
|
||||
claimedBy: durableRow.claimedBy,
|
||||
})
|
||||
continue
|
||||
}
|
||||
readyTools.push({
|
||||
toolCallId,
|
||||
toolState,
|
||||
durableRow,
|
||||
needsDurableClaim: durableRow.claimedBy !== resumeWorkerId,
|
||||
alreadyClaimedByWorker: durableRow.claimedBy === resumeWorkerId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
!durableRow &&
|
||||
toolState &&
|
||||
isTerminalToolCallStatus(toolState.status) &&
|
||||
!isToolAvailableOnSimSide(toolState.name)
|
||||
) {
|
||||
logger.info('Including Go-handled tool in resume payload (no Sim-side row)', {
|
||||
toolCallId,
|
||||
toolName: toolState.name,
|
||||
status: toolState.status,
|
||||
runId: continuation.runId,
|
||||
})
|
||||
claimableToolCallIds.push(toolCallId)
|
||||
readyTools.push({
|
||||
toolCallId,
|
||||
toolState,
|
||||
needsDurableClaim: false,
|
||||
alreadyClaimedByWorker: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.warn('Skipping already-claimed or missing async tool resume', {
|
||||
toolCallId,
|
||||
runId: continuation.runId,
|
||||
durableStatus: durableRow?.status,
|
||||
toolStateStatus: toolState?.status,
|
||||
})
|
||||
missingToolCallIds.push(toolCallId)
|
||||
}
|
||||
|
||||
if (localPendingPromises.length > 0) {
|
||||
@@ -245,30 +272,104 @@ export async function orchestrateCopilotStream(
|
||||
continue
|
||||
}
|
||||
|
||||
if (claimableToolCallIds.length === 0) {
|
||||
logger.warn('Skipping async resume because no tool calls were claimable', {
|
||||
checkpointId: continuation.checkpointId,
|
||||
runId: continuation.runId,
|
||||
})
|
||||
context.awaitingAsyncContinuation = undefined
|
||||
break
|
||||
if (missingToolCallIds.length > 0) {
|
||||
if (resumeRetries < 3) {
|
||||
resumeRetries++
|
||||
logger.info('Retrying async resume after some tool calls were not yet ready', {
|
||||
checkpointId: continuation.checkpointId,
|
||||
runId: continuation.runId,
|
||||
retry: resumeRetries,
|
||||
missingToolCallIds,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
|
||||
continue
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to resume async tool continuation: pending tool calls were not ready (${missingToolCallIds.join(', ')})`
|
||||
)
|
||||
}
|
||||
|
||||
if (readyTools.length === 0) {
|
||||
if (resumeRetries < 3 && continuation.pendingToolCallIds.length > 0) {
|
||||
resumeRetries++
|
||||
logger.info('Retrying async resume because no tool calls were ready yet', {
|
||||
checkpointId: continuation.checkpointId,
|
||||
runId: continuation.runId,
|
||||
retry: resumeRetries,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
|
||||
continue
|
||||
}
|
||||
throw new Error('Failed to resume async tool continuation: no tool calls were ready')
|
||||
}
|
||||
|
||||
const claimCandidates = readyTools.filter((tool) => tool.needsDurableClaim)
|
||||
const newlyClaimedToolCallIds: string[] = []
|
||||
const claimFailures: string[] = []
|
||||
|
||||
for (const tool of claimCandidates) {
|
||||
const claimed = await claimCompletedAsyncToolCall(tool.toolCallId, resumeWorkerId).catch(
|
||||
() => null
|
||||
)
|
||||
if (!claimed) {
|
||||
claimFailures.push(tool.toolCallId)
|
||||
continue
|
||||
}
|
||||
newlyClaimedToolCallIds.push(tool.toolCallId)
|
||||
}
|
||||
|
||||
if (claimFailures.length > 0) {
|
||||
if (newlyClaimedToolCallIds.length > 0) {
|
||||
logger.info('Releasing async tool claims after claim contention during resume', {
|
||||
checkpointId: continuation.checkpointId,
|
||||
runId: continuation.runId,
|
||||
newlyClaimedToolCallIds,
|
||||
claimFailures,
|
||||
})
|
||||
await Promise.all(
|
||||
newlyClaimedToolCallIds.map((toolCallId) =>
|
||||
releaseCompletedAsyncToolClaim(toolCallId, resumeWorkerId).catch(() => null)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (resumeRetries < 3) {
|
||||
resumeRetries++
|
||||
logger.info('Retrying async resume after claim contention', {
|
||||
checkpointId: continuation.checkpointId,
|
||||
runId: continuation.runId,
|
||||
retry: resumeRetries,
|
||||
claimFailures,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
|
||||
continue
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to resume async tool continuation: unable to claim tool calls (${claimFailures.join(', ')})`
|
||||
)
|
||||
}
|
||||
|
||||
claimedToolCallIds = [
|
||||
...readyTools
|
||||
.filter((tool) => tool.alreadyClaimedByWorker)
|
||||
.map((tool) => tool.toolCallId),
|
||||
...newlyClaimedToolCallIds,
|
||||
]
|
||||
claimedByWorkerId = claimedToolCallIds.length > 0 ? resumeWorkerId : null
|
||||
|
||||
logger.info('Resuming async tool continuation', {
|
||||
checkpointId: continuation.checkpointId,
|
||||
runId: continuation.runId,
|
||||
toolCallIds: claimableToolCallIds,
|
||||
toolCallIds: readyTools.map((tool) => tool.toolCallId),
|
||||
})
|
||||
|
||||
const durableRows = await getAsyncToolCalls(claimableToolCallIds).catch(() => [])
|
||||
const durableRows = await getAsyncToolCalls(
|
||||
readyTools.map((tool) => tool.toolCallId)
|
||||
).catch(() => [])
|
||||
const durableByToolCallId = new Map(durableRows.map((row) => [row.toolCallId, row]))
|
||||
|
||||
const results = await Promise.all(
|
||||
claimableToolCallIds.map(async (toolCallId) => {
|
||||
const completion = await context.pendingToolPromises.get(toolCallId)
|
||||
const toolState = context.toolCalls.get(toolCallId)
|
||||
|
||||
const durable = durableByToolCallId.get(toolCallId)
|
||||
readyTools.map(async (tool) => {
|
||||
const durable = durableByToolCallId.get(tool.toolCallId) || tool.durableRow
|
||||
const durableStatus = durable?.status
|
||||
const durableResult =
|
||||
durable?.result && typeof durable.result === 'object'
|
||||
@@ -278,19 +379,15 @@ export async function orchestrateCopilotStream(
|
||||
durableStatus,
|
||||
durableResult,
|
||||
durableError: durable?.error,
|
||||
completion,
|
||||
toolStateSuccess: toolState?.result?.success,
|
||||
toolStateStatus: toolState?.status,
|
||||
toolStateStatus: tool.toolState?.status,
|
||||
})
|
||||
const data =
|
||||
durableResult ||
|
||||
completion?.data ||
|
||||
(toolState?.result?.output as Record<string, unknown> | undefined) ||
|
||||
(tool.toolState?.result?.output as Record<string, unknown> | undefined) ||
|
||||
(success
|
||||
? { message: completion?.message || 'Tool completed' }
|
||||
? { message: 'Tool completed' }
|
||||
: {
|
||||
error:
|
||||
completion?.message || durable?.error || toolState?.error || 'Tool failed',
|
||||
error: durable?.error || tool.toolState?.error || 'Tool failed',
|
||||
})
|
||||
|
||||
if (
|
||||
@@ -299,14 +396,14 @@ export async function orchestrateCopilotStream(
|
||||
!isDeliveredAsyncStatus(durableStatus)
|
||||
) {
|
||||
logger.warn('Async tool row was claimed for resume without terminal durable state', {
|
||||
toolCallId,
|
||||
toolCallId: tool.toolCallId,
|
||||
status: durableStatus,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
callId: toolCallId,
|
||||
name: durable?.toolName || toolState?.name || '',
|
||||
callId: tool.toolCallId,
|
||||
name: durable?.toolName || tool.toolState?.name || '',
|
||||
data,
|
||||
success,
|
||||
}
|
||||
|
||||
@@ -209,4 +209,76 @@ describe('sse-handlers tool lifecycle', () => {
|
||||
expect(markToolComplete).toHaveBeenCalledTimes(1)
|
||||
expect(context.toolCalls.get('tool-upsert-fail')?.status).toBe('success')
|
||||
})
|
||||
|
||||
it('does not execute a tool if a terminal tool_result arrives before local execution starts', async () => {
|
||||
let resolveUpsert: ((value: null) => void) | undefined
|
||||
upsertAsyncToolCall.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveUpsert = resolve
|
||||
})
|
||||
)
|
||||
const onEvent = vi.fn()
|
||||
|
||||
await sseHandlers.tool_call(
|
||||
{
|
||||
type: 'tool_call',
|
||||
data: { id: 'tool-race', name: 'read', arguments: { workflowId: 'workflow-1' } },
|
||||
} as any,
|
||||
context,
|
||||
execContext,
|
||||
{ onEvent, interactive: false, timeout: 1000 }
|
||||
)
|
||||
|
||||
await sseHandlers.tool_result(
|
||||
{
|
||||
type: 'tool_result',
|
||||
toolCallId: 'tool-race',
|
||||
data: { id: 'tool-race', success: true, result: { ok: true } },
|
||||
} as any,
|
||||
context,
|
||||
execContext,
|
||||
{ onEvent, interactive: false, timeout: 1000 }
|
||||
)
|
||||
|
||||
resolveUpsert?.(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(executeToolServerSide).not.toHaveBeenCalled()
|
||||
expect(markToolComplete).not.toHaveBeenCalled()
|
||||
expect(context.toolCalls.get('tool-race')?.status).toBe('success')
|
||||
expect(context.toolCalls.get('tool-race')?.result?.output).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('does not execute a tool if a tool_result arrives before the tool_call event', async () => {
|
||||
const onEvent = vi.fn()
|
||||
|
||||
await sseHandlers.tool_result(
|
||||
{
|
||||
type: 'tool_result',
|
||||
toolCallId: 'tool-early-result',
|
||||
toolName: 'read',
|
||||
data: { id: 'tool-early-result', name: 'read', success: true, result: { ok: true } },
|
||||
} as any,
|
||||
context,
|
||||
execContext,
|
||||
{ onEvent, interactive: false, timeout: 1000 }
|
||||
)
|
||||
|
||||
await sseHandlers.tool_call(
|
||||
{
|
||||
type: 'tool_call',
|
||||
data: { id: 'tool-early-result', name: 'read', arguments: { workflowId: 'workflow-1' } },
|
||||
} as any,
|
||||
context,
|
||||
execContext,
|
||||
{ onEvent, interactive: false, timeout: 1000 }
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(executeToolServerSide).not.toHaveBeenCalled()
|
||||
expect(markToolComplete).not.toHaveBeenCalled()
|
||||
expect(context.toolCalls.get('tool-early-result')?.status).toBe('success')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -213,6 +213,27 @@ function inferToolSuccess(data: Record<string, unknown> | undefined): {
|
||||
return { success, hasResultData, hasError }
|
||||
}
|
||||
|
||||
function ensureTerminalToolCallState(
|
||||
context: StreamingContext,
|
||||
toolCallId: string,
|
||||
toolName: string
|
||||
): ToolCallState {
|
||||
const existing = context.toolCalls.get(toolCallId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const toolCall: ToolCallState = {
|
||||
id: toolCallId,
|
||||
name: toolName || 'unknown_tool',
|
||||
status: 'pending',
|
||||
startTime: Date.now(),
|
||||
}
|
||||
context.toolCalls.set(toolCallId, toolCall)
|
||||
addContentBlock(context, { type: 'tool_call', toolCall })
|
||||
return toolCall
|
||||
}
|
||||
|
||||
export type SSEHandler = (
|
||||
event: SSEEvent,
|
||||
context: StreamingContext,
|
||||
@@ -246,8 +267,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const data = getEventData(event)
|
||||
const toolCallId = event.toolCallId || (data?.id as string | undefined)
|
||||
if (!toolCallId) return
|
||||
const current = context.toolCalls.get(toolCallId)
|
||||
if (!current) return
|
||||
const toolName =
|
||||
event.toolName ||
|
||||
(data?.name as string | undefined) ||
|
||||
context.toolCalls.get(toolCallId)?.name ||
|
||||
''
|
||||
const current = ensureTerminalToolCallState(context, toolCallId, toolName)
|
||||
|
||||
const { success, hasResultData, hasError } = inferToolSuccess(data)
|
||||
|
||||
@@ -263,16 +288,22 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const resultObj = asRecord(data?.result)
|
||||
current.error = (data?.error || resultObj.error) as string | undefined
|
||||
}
|
||||
markToolResultSeen(toolCallId)
|
||||
},
|
||||
tool_error: (event, context) => {
|
||||
const data = getEventData(event)
|
||||
const toolCallId = event.toolCallId || (data?.id as string | undefined)
|
||||
if (!toolCallId) return
|
||||
const current = context.toolCalls.get(toolCallId)
|
||||
if (!current) return
|
||||
const toolName =
|
||||
event.toolName ||
|
||||
(data?.name as string | undefined) ||
|
||||
context.toolCalls.get(toolCallId)?.name ||
|
||||
''
|
||||
const current = ensureTerminalToolCallState(context, toolCallId, toolName)
|
||||
current.status = 'error'
|
||||
current.error = (data?.error as string | undefined) || 'Tool execution failed'
|
||||
current.endTime = Date.now()
|
||||
markToolResultSeen(toolCallId)
|
||||
},
|
||||
tool_call_delta: () => {
|
||||
// Argument streaming delta — no action needed on orchestrator side
|
||||
@@ -313,6 +344,9 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
existing?.endTime ||
|
||||
(existing && existing.status !== 'pending' && existing.status !== 'executing')
|
||||
) {
|
||||
if (!existing.name && toolName) {
|
||||
existing.name = toolName
|
||||
}
|
||||
if (!existing.params && args) {
|
||||
existing.params = args
|
||||
}
|
||||
@@ -558,6 +592,12 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
const existing = context.toolCalls.get(toolCallId)
|
||||
// Ignore late/duplicate tool_call events once we already have a result.
|
||||
if (wasToolResultSeen(toolCallId) || existing?.endTime) {
|
||||
if (existing && !existing.name && toolName) {
|
||||
existing.name = toolName
|
||||
}
|
||||
if (existing && !existing.params && args) {
|
||||
existing.params = args
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -686,13 +726,14 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
const data = getEventData(event)
|
||||
const toolCallId = event.toolCallId || (data?.id as string | undefined)
|
||||
if (!toolCallId) return
|
||||
const toolName = event.toolName || (data?.name as string | undefined) || ''
|
||||
|
||||
// Update in subAgentToolCalls.
|
||||
const toolCalls = context.subAgentToolCalls[parentToolCallId] || []
|
||||
const subAgentToolCall = toolCalls.find((tc) => tc.id === toolCallId)
|
||||
|
||||
// Also update in main toolCalls (where we added it for execution).
|
||||
const mainToolCall = context.toolCalls.get(toolCallId)
|
||||
const mainToolCall = ensureTerminalToolCallState(context, toolCallId, toolName)
|
||||
|
||||
const { success, hasResultData, hasError } = inferToolSuccess(data)
|
||||
|
||||
@@ -719,6 +760,9 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
mainToolCall.error = (data?.error || resultObj.error) as string | undefined
|
||||
}
|
||||
}
|
||||
if (subAgentToolCall || mainToolCall) {
|
||||
markToolResultSeen(toolCallId)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,15 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { completeAsyncToolCall, markAsyncToolRunning } from '@/lib/copilot/async-runs/repository'
|
||||
import { waitForToolConfirmation } from '@/lib/copilot/orchestrator/persistence'
|
||||
import {
|
||||
asRecord,
|
||||
markToolResultSeen,
|
||||
wasToolResultSeen,
|
||||
} from '@/lib/copilot/orchestrator/sse/utils'
|
||||
import { asRecord, markToolResultSeen } from '@/lib/copilot/orchestrator/sse/utils'
|
||||
import { executeToolServerSide, markToolComplete } from '@/lib/copilot/orchestrator/tool-executor'
|
||||
import type {
|
||||
ExecutionContext,
|
||||
OrchestratorOptions,
|
||||
SSEEvent,
|
||||
StreamingContext,
|
||||
ToolCallResult,
|
||||
import {
|
||||
type ExecutionContext,
|
||||
isTerminalToolCallStatus,
|
||||
type OrchestratorOptions,
|
||||
type SSEEvent,
|
||||
type StreamingContext,
|
||||
type ToolCallResult,
|
||||
} from '@/lib/copilot/orchestrator/types'
|
||||
import {
|
||||
extractDeletedResourcesFromToolResult,
|
||||
@@ -117,6 +114,20 @@ const FORMAT_TO_CONTENT_TYPE: Record<OutputFormat, string> = {
|
||||
html: 'text/html',
|
||||
}
|
||||
|
||||
function normalizeOutputWorkspaceFileName(outputPath: string): string {
|
||||
const trimmed = outputPath.trim().replace(/^\/+/, '')
|
||||
const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed
|
||||
if (!withoutPrefix) {
|
||||
throw new Error('outputPath must include a file name, e.g. "files/result.json"')
|
||||
}
|
||||
if (withoutPrefix.includes('/')) {
|
||||
throw new Error(
|
||||
'outputPath must target a flat workspace file, e.g. "files/result.json". Nested paths like "files/reports/result.json" are not supported.'
|
||||
)
|
||||
}
|
||||
return withoutPrefix
|
||||
}
|
||||
|
||||
function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat {
|
||||
if (explicit && explicit in FORMAT_TO_CONTENT_TYPE) return explicit as OutputFormat
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
|
||||
@@ -153,10 +164,10 @@ async function maybeWriteOutputToFile(
|
||||
|
||||
const explicitFormat =
|
||||
(params?.outputFormat as string | undefined) ?? (args?.outputFormat as string | undefined)
|
||||
const fileName = outputPath.replace(/^files\//, '')
|
||||
const format = resolveOutputFormat(fileName, explicitFormat)
|
||||
|
||||
try {
|
||||
const fileName = normalizeOutputWorkspaceFileName(outputPath)
|
||||
const format = resolveOutputFormat(fileName, explicitFormat)
|
||||
if (context.abortSignal?.aborted) {
|
||||
throw new Error('Request aborted before tool mutation could be applied')
|
||||
}
|
||||
@@ -193,12 +204,16 @@ async function maybeWriteOutputToFile(
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.warn('Failed to write tool output to file', {
|
||||
toolName,
|
||||
outputPath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: message,
|
||||
})
|
||||
return result
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to write output file: ${message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +244,48 @@ function cancelledCompletion(message: string): AsyncToolCompletion {
|
||||
}
|
||||
}
|
||||
|
||||
function terminalCompletionFromToolCall(toolCall: {
|
||||
status: string
|
||||
error?: string
|
||||
result?: { output?: unknown; error?: string }
|
||||
}): AsyncToolCompletion {
|
||||
if (toolCall.status === 'cancelled') {
|
||||
return cancelledCompletion(toolCall.error || 'Tool execution cancelled')
|
||||
}
|
||||
|
||||
if (toolCall.status === 'success') {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Tool completed',
|
||||
data:
|
||||
toolCall.result?.output &&
|
||||
typeof toolCall.result.output === 'object' &&
|
||||
!Array.isArray(toolCall.result.output)
|
||||
? (toolCall.result.output as Record<string, unknown>)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCall.status === 'skipped') {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Tool skipped',
|
||||
data:
|
||||
toolCall.result?.output &&
|
||||
typeof toolCall.result.output === 'object' &&
|
||||
!Array.isArray(toolCall.result.output)
|
||||
? (toolCall.result.output as Record<string, unknown>)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: toolCall.status === 'rejected' ? 'rejected' : 'error',
|
||||
message: toolCall.error || toolCall.result?.error || 'Tool failed',
|
||||
data: { error: toolCall.error || toolCall.result?.error || 'Tool failed' },
|
||||
}
|
||||
}
|
||||
|
||||
function reportCancelledTool(
|
||||
toolCall: { id: string; name: string },
|
||||
message: string,
|
||||
@@ -491,8 +548,8 @@ export async function executeToolAndReport(
|
||||
if (toolCall.status === 'executing') {
|
||||
return { status: 'running', message: 'Tool already executing' }
|
||||
}
|
||||
if (wasToolResultSeen(toolCall.id)) {
|
||||
return { status: 'success', message: 'Tool result already processed' }
|
||||
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
|
||||
return terminalCompletionFromToolCall(toolCall)
|
||||
}
|
||||
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
@@ -520,6 +577,9 @@ export async function executeToolAndReport(
|
||||
|
||||
try {
|
||||
let result = await executeToolServerSide(toolCall, execContext)
|
||||
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
|
||||
return terminalCompletionFromToolCall(toolCall)
|
||||
}
|
||||
if (abortRequested(context, execContext, options)) {
|
||||
toolCall.status = 'cancelled'
|
||||
toolCall.endTime = Date.now()
|
||||
@@ -581,10 +641,17 @@ export async function executeToolAndReport(
|
||||
toolCall.endTime = Date.now()
|
||||
|
||||
if (result.success) {
|
||||
const raw = result.output
|
||||
const preview =
|
||||
typeof raw === 'string'
|
||||
? raw.slice(0, 200)
|
||||
: raw && typeof raw === 'object'
|
||||
? JSON.stringify(raw).slice(0, 200)
|
||||
: undefined
|
||||
logger.info('Tool execution succeeded', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
output: result.output,
|
||||
outputPreview: preview,
|
||||
})
|
||||
} else {
|
||||
logger.warn('Tool execution failed', {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
markToolResultSeen,
|
||||
normalizeSseEvent,
|
||||
shouldSkipToolCallEvent,
|
||||
shouldSkipToolResultEvent,
|
||||
@@ -37,6 +38,7 @@ describe('sse-utils', () => {
|
||||
it.concurrent('dedupes tool_result events', () => {
|
||||
const event = { type: 'tool_result', data: { id: 'tool_result_1', name: 'plan' } }
|
||||
expect(shouldSkipToolResultEvent(event as any)).toBe(false)
|
||||
markToolResultSeen('tool_result_1')
|
||||
expect(shouldSkipToolResultEvent(event as any)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,7 +125,5 @@ export function shouldSkipToolResultEvent(event: SSEEvent): boolean {
|
||||
if (event.type !== 'tool_result') return false
|
||||
const toolCallId = getToolCallIdFromEvent(event)
|
||||
if (!toolCallId) return false
|
||||
if (wasToolResultSeen(toolCallId)) return true
|
||||
markToolResultSeen(toolCallId)
|
||||
return false
|
||||
return wasToolResultSeen(toolCallId)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user