Compare commits

...

20 Commits

Author SHA1 Message Date
Waleed
7b572f1f61 v0.6.10: tour fix, connectors reliability improvements, tooltip gif fixes 2026-03-24 21:38:19 -07:00
Vikhyath Mondreti
b497033795 Revert "improvement(mothership): show continue options on abort (#3746)" (#3756)
This reverts commit b9926df8e0.
2026-03-24 21:08:52 -07:00
Waleed
666dc67aa2 fix(db): use bigint for token counter columns in user_stats (#3755) 2026-03-24 21:08:07 -07:00
Waleed
7af7a225f2 fix(knowledge): route connector doc processing through queue instead of fire-and-forget (#3754)
* fix(knowledge): route connector doc processing through queue instead of fire-and-forget

* fix(knowledge): rename jobIds to batchIds in processDocumentsWithTrigger return type

* improvement(knowledge): add Trigger.dev tags for connector sync and document processing tasks

* fix(knowledge): move completeSyncLog after doc enqueue, handle NULL processingStartedAt in stuck doc query
2026-03-24 21:07:55 -07:00
Waleed
228578e282 fix(auth): remove captcha from login, fix signup captcha flow (#3753)
* fix(auth): remove captcha from login, fix signup captcha flow

* fix(auth): show Turnstile widget at normal size for Managed mode challenges
2026-03-24 20:36:49 -07:00
Waleed
be647469ac fix(ui): constrain tooltip width and remove question mark cursor (#3752)
- Add max-w-[260px] to Tooltip.Content so video previews don't blow out the tooltip size
- Replace cursor-help with cursor-default on info icons in settings
2026-03-24 19:01:45 -07:00
Waleed
96b171cf74 improvement(tour): fix tour auto-start logic and standardize selectors (#3751)
* improvement(tour): fix tour auto-start logic and standardize selectors

* fix(tour): address PR review comments

- Move autoStartAttempted.add() inside timer callback to prevent
  blocking auto-start when tour first mounts while disabled
- Memoize setJoyrideRef with useCallback to prevent ref churn
- Remove unused joyrideRef
2026-03-24 18:32:17 -07:00
Theodore Li
cdea2404e3 improvement(ui): Merge ui components for mothership chat (#3748)
* improvement(ui): Merge ui definitions for mothership chat

* Fix lint

* Restore copilot layout

* Fix subagent text not animating collapses

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-24 20:19:04 -04:00
Vikhyath Mondreti
ed9a71f0af v0.6.9: general ux improvements for tables, mothership 2026-03-24 17:03:24 -07:00
Waleed
f6975fc0a3 feat(settings): add video tooltip previews for canvas settings (#3749)
* feat(settings): add video tooltip previews for canvas settings

* fix(tooltip): add preload=none and handle query strings in video detection
2026-03-24 16:56:10 -07:00
Theodore Li
59182d5db2 feat(admin): Add assume user capability (#3742)
* Allow admin users to assume user sessions

* Add explicit role check

* Fix lint

* Remove admin panel when impersonating

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-24 19:38:06 -04:00
Siddharth Ganesan
b9926df8e0 improvement(mothership): show continue options on abort (#3746)
* Show continue options on abort

* Fix lint

* Fix
2026-03-24 16:08:28 -07:00
Waleed
77eafabb63 feat(billing): add appliesTo plan restriction for coupon codes (#3744)
* feat(billing): add appliesTo plan restriction for coupon codes

* fix(billing): fail coupon creation on partial product resolution
2026-03-24 13:04:55 -07:00
Waleed
34ea99e99d feat(home): auth-aware landing page navigation (#3743)
* feat(home): auth-aware landing page navigation

- Redirect authenticated users from / to /workspace via middleware (?home param bypasses)
- Show "Go to App" instead of "Log in / Get started" in navbar for authenticated users
- Logo links to /?home for authenticated users to stay in marketing context
- Settings "Home Page" button opens /?home
- Handle isPending session state to prevent CTA button flash

* lint

* fix(home): remove stale ?from=nav params in landing nav

* fix(home): preserve ?home param in nav links during session pending state

* lint
2026-03-24 12:59:29 -07:00
Waleed
a7f344bca1 feat(tour): added product tour (#3703)
* feat: add product tour

* chore: updated modals

* chore: fix the tour

* chore: Tour Updates

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes

* minor improvements

* chore(tour): address PR review comments

- Extract shared TourState, TourStateContext, mapPlacement, and TourTooltipAdapter
  into tour-shared.tsx, eliminating ~100 lines of duplication between product-tour.tsx
  and workflow-tour.tsx
- Fix stale closure in handleStartTour — add isOnWorkflowPage to useCallback deps
  so Take a tour dispatches the correct event after navigation

* chore(tour): address remaining PR review comments

- Remove unused logger import and instance in product-tour.tsx
- Remove unused tour-tooltip-fade animation from tailwind config
- Remove unnecessary overflow-hidden wrapper around WorkflowTour
- Add border stroke to arrow SVG in tour-tooltip for visual consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(tour): address second round of PR review comments

- Remove unnecessary 'use client' from workflow layout (children are already client components)
- Fix ref guard timing issue in TourTooltipAdapter that could prevent Joyride from tracking tooltip on subsequent steps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(tour): extract shared Joyride config, fix popover arrow overflow

- Extract duplicated Joyride floaterProps/styles into getSharedJoyrideProps()
  in tour-shared.tsx, parameterized by spotlightBorderRadius
- Fix showArrow disabling content scrolling in PopoverContent by wrapping
  children in a scrollable div when arrow is visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(tour): stop running tour when disabled becomes true

Prevents nav and workflow tours from overlapping. When a user navigates
to a workflow page while the nav tour is running, the disabled flag
now stops the nav tour instead of just suppressing auto-start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tour): move auto-start flag into timer, fix truncate selector conflict

- Move hasAutoStarted flag inside setTimeout callback so it's only set
  when the timer fires, allowing retry if disabled changes during delay
- Add data-popover-scroll attribute to showArrow scroll wrapper and
  exclude it from the flex-1 truncate selector to prevent overflow
  conflict

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tour): remove duplicate overlay on center-placed tour steps

Joyride's spotlight already renders a full-screen overlay via boxShadow.
The centered TourTooltip was adding its own bg-black/55 overlay on top,
causing double-darkened backgrounds. Removed the redundant overlay div.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move docs link from settings to help dropdown

The Docs link (https://docs.sim.ai) was buried in settings navigation.
Moved it to the Help dropdown in the sidebar for better discoverability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Adithya Krishna <aadithya794@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:45:16 -07:00
Adithya Krishna
7b6149dc23 chore: optimize imports and useShallow (#3740)
* chore: fix conflicts

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes
2026-03-24 10:51:09 -07:00
Waleed
b09a073c29 feat(table): column drag-and-drop reorder (#3738)
* feat(table): column drag-and-drop reorder

* fix(table): remove duplicate onDragEnd call from handleDrop

* fix(table): persist columnOrder on rename/delete and defer delete to onSuccess

* fix(table): prevent stale refs during column drag operations

Fix two bugs in column drag-and-drop:
1. Stale columnWidths ref during rename - compute updated widths inline
   before passing to updateMetadata
2. Escape-cancelled drag still reorders - update dropTargetColumnNameRef
   directly in handleColumnDragLeave to prevent handleColumnDragEnd from
   reading stale ref value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(table): insert column at correct side when anchor is unordered

When the anchor column isn't in columnOrder, add it first then insert
the new column relative to it, so 'right' insertions appear after the
anchor as expected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:15:48 -07:00
Adithya Krishna
8d93c850ba chore: remove lodash (#3741) 2026-03-24 10:10:20 -07:00
Waleed
83eb3ed211 fix(home): voice input text persistence bugs (#3737)
* fix(home): voice input text persistence bugs

* fix(home): gate setIsListening on startRecognition success

* fix(home): handle startRecognition failure in restartRecognition

* fix(home): reset speech prefix on submit while mic is active
2026-03-24 09:55:13 -07:00
Waleed
a783b9d4ce fix(integrations): remove outdated trigger mode text from FAQ (#3739) 2026-03-24 06:47:50 -07:00
105 changed files with 17845 additions and 1157 deletions

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>
</>

View File

@@ -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'>

View File

@@ -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'>

View File

@@ -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

View File

@@ -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>

View File

@@ -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?`,

View File

@@ -21,6 +21,7 @@ export type AppSession = {
id?: string
userId?: string
activeOrganizationId?: string
impersonatedBy?: string | null
}
} | null

View File

@@ -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(),
}),
})

View File

@@ -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))

View File

@@ -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')

View File

@@ -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)]' />

View File

@@ -0,0 +1,2 @@
export { NavTour, START_NAV_TOUR_EVENT } from './product-tour'
export { START_WORKFLOW_TOUR_EVENT, WorkflowTour } from './workflow-tour'

View File

@@ -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,
},
]

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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,
},
]

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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'>

View File

@@ -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}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 */}

View File

@@ -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'

View File

@@ -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,

View File

@@ -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')
},
})
}
/>
)
}

View File

@@ -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>

View File

@@ -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)]'>

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]'

View File

@@ -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'
>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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)]' />

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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>

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)

View File

@@ -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,

View File

@@ -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'

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

@@ -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'

View File

@@ -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 */}

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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}`}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
/**

View File

@@ -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(() => {

View File

@@ -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)

View File

@@ -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', {

View 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>
)
}

View File

@@ -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'

View File

@@ -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>
)

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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(),
})
},
})

View File

@@ -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)
},
})
}

View File

@@ -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),
})
},
})
}

View File

@@ -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,
})
}

View File

@@ -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),
})
},
})

View File

@@ -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,
})
}

View File

@@ -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 })

View File

@@ -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) })
},
})
}

View File

@@ -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,
})
}

View File

@@ -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) })
},
})
}

View File

@@ -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

View File

@@ -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) => (

View File

@@ -6,13 +6,14 @@ import {
knowledgeConnectorSyncLog,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm'
import { and, eq, gt, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'
import { decryptApiKey } from '@/lib/api-key/crypto'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import type { DocumentData } from '@/lib/knowledge/documents/service'
import {
hardDeleteDocuments,
isTriggerAvailable,
processDocumentAsync,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import { StorageService } from '@/lib/uploads'
import { deleteFile } from '@/lib/uploads/core/storage-service'
@@ -39,6 +40,8 @@ class ConnectorDeletedException extends Error {
const SYNC_BATCH_SIZE = 5
const MAX_PAGES = 500
const MAX_SAFE_TITLE_LENGTH = 200
const STALE_PROCESSING_MINUTES = 45
const RETRY_WINDOW_DAYS = 7
/** Sanitizes a document title for use in S3 storage keys. */
function sanitizeStorageTitle(title: string): string {
@@ -147,11 +150,14 @@ export async function dispatchSync(
const requestId = options?.requestId ?? crypto.randomUUID()
if (isTriggerAvailable()) {
await knowledgeConnectorSync.trigger({
connectorId,
fullSync: options?.fullSync,
requestId,
})
await knowledgeConnectorSync.trigger(
{
connectorId,
fullSync: options?.fullSync,
requestId,
},
{ tags: [`connector:${connectorId}`] }
)
logger.info(`Dispatched connector sync to Trigger.dev`, { connectorId, requestId })
} else {
executeSync(connectorId, { fullSync: options?.fullSync }).catch((error) => {
@@ -395,6 +401,8 @@ export async function executeSync(
const seenExternalIds = new Set<string>()
const pendingProcessing: DocumentData[] = []
const pendingOps: DocOp[] = []
for (const extDoc of externalDocs) {
seenExternalIds.add(extDoc.externalId)
@@ -503,6 +511,7 @@ export async function executeSync(
for (let j = 0; j < settled.length; j++) {
const outcome = settled[j]
if (outcome.status === 'fulfilled') {
pendingProcessing.push(outcome.value)
if (batch[j].type === 'add') result.docsAdded++
else result.docsUpdated++
} else {
@@ -537,9 +546,14 @@ export async function executeSync(
throw new Error(`Knowledge base ${connector.knowledgeBaseId} was deleted during sync`)
}
// Retry stuck documents that failed or never completed processing.
// Retry stuck documents that failed, never started, or were abandoned mid-processing.
// Only retry docs uploaded BEFORE this sync — docs added in the current sync
// are still processing asynchronously and would cause a duplicate processing race.
// Documents stuck in 'processing' beyond STALE_PROCESSING_MINUTES are considered
// abandoned (e.g. the Trigger.dev task process exited before processing completed).
// Documents uploaded more than RETRY_WINDOW_DAYS ago are not retried.
const staleProcessingCutoff = new Date(Date.now() - STALE_PROCESSING_MINUTES * 60 * 1000)
const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000)
const stuckDocs = await db
.select({
id: document.id,
@@ -552,8 +566,18 @@ export async function executeSync(
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.processingStatus, ['pending', 'failed']),
or(
inArray(document.processingStatus, ['pending', 'failed']),
and(
eq(document.processingStatus, 'processing'),
or(
isNull(document.processingStartedAt),
lt(document.processingStartedAt, staleProcessingCutoff)
)
)
),
lt(document.uploadedAt, syncStartedAt),
gt(document.uploadedAt, retryCutoff),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
@@ -562,28 +586,60 @@ export async function executeSync(
if (stuckDocs.length > 0) {
logger.info(`Retrying ${stuckDocs.length} stuck documents`, { connectorId })
for (const doc of stuckDocs) {
processDocumentAsync(
connector.knowledgeBaseId,
doc.id,
{
try {
await processDocumentsWithQueue(
stuckDocs.map((doc) => ({
documentId: doc.id,
filename: doc.filename ?? 'document.txt',
fileUrl: doc.fileUrl ?? '',
fileSize: doc.fileSize ?? 0,
mimeType: doc.mimeType ?? 'text/plain',
},
{}
).catch((error) => {
logger.warn('Failed to retry stuck document', {
documentId: doc.id,
error: error instanceof Error ? error.message : String(error),
})
})),
connector.knowledgeBaseId,
{},
crypto.randomUUID()
)
} catch (error) {
logger.warn('Failed to enqueue stuck documents for reprocessing', {
connectorId,
count: stuckDocs.length,
error: error instanceof Error ? error.message : String(error),
})
}
}
// Enqueue all added/updated documents for processing in a single batch
if (pendingProcessing.length > 0) {
try {
await processDocumentsWithQueue(
pendingProcessing,
connector.knowledgeBaseId,
{},
crypto.randomUUID()
)
} catch (error) {
logger.warn('Failed to enqueue documents for processing — will retry on next sync', {
connectorId,
count: pendingProcessing.length,
error: error instanceof Error ? error.message : String(error),
})
}
}
await completeSyncLog(syncLogId, 'completed', result)
const [{ count: actualDocCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
const now = new Date()
await db
.update(knowledgeConnector)
@@ -591,7 +647,7 @@ export async function executeSync(
status: 'active',
lastSyncAt: now,
lastSyncError: null,
lastSyncDocCount: externalDocs.length,
lastSyncDocCount: actualDocCount,
nextSyncAt: calculateNextSyncTime(connector.syncIntervalMinutes),
consecutiveFailures: 0,
updatedAt: now,
@@ -711,7 +767,7 @@ async function addDocument(
connectorType: string,
extDoc: ExternalDocument,
sourceConfig?: Record<string, unknown>
): Promise<void> {
): Promise<DocumentData> {
if (await isKnowledgeBaseDeleted(knowledgeBaseId)) {
throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`)
}
@@ -773,23 +829,13 @@ async function addDocument(
throw error
}
processDocumentAsync(
knowledgeBaseId,
return {
documentId,
{
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
},
{}
).catch((error) => {
logger.error('Failed to process connector document', {
documentId,
connectorId,
error: error instanceof Error ? error.message : String(error),
})
})
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
}
}
/**
@@ -803,7 +849,7 @@ async function updateDocument(
connectorType: string,
extDoc: ExternalDocument,
sourceConfig?: Record<string, unknown>
): Promise<void> {
): Promise<DocumentData> {
if (await isKnowledgeBaseDeleted(knowledgeBaseId)) {
throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`)
}
@@ -894,21 +940,11 @@ async function updateDocument(
}
}
processDocumentAsync(
knowledgeBaseId,
existingDocId,
{
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
},
{}
).catch((error) => {
logger.error('Failed to re-process updated connector document', {
documentId: existingDocId,
connectorId,
error: error instanceof Error ? error.message : String(error),
})
})
return {
documentId: existingDocId,
filename: processingFilename,
fileUrl,
fileSize: contentBuffer.length,
mimeType: 'text/plain',
}
}

View File

@@ -114,11 +114,11 @@ export interface DocumentData {
}
export interface ProcessingOptions {
chunkSize: number
minCharactersPerChunk: number
recipe: string
lang: string
chunkOverlap: number
chunkSize?: number
minCharactersPerChunk?: number
recipe?: string
lang?: string
chunkOverlap?: number
}
export interface DocumentJobData {
@@ -668,7 +668,7 @@ export function isTriggerAvailable(): boolean {
export async function processDocumentsWithTrigger(
documents: DocumentProcessingPayload[],
requestId: string
): Promise<{ success: boolean; message: string; jobIds?: string[] }> {
): Promise<{ success: boolean; message: string; batchIds?: string[] }> {
if (!isTriggerAvailable()) {
throw new Error('Trigger.dev is not configured - TRIGGER_SECRET_KEY missing')
}
@@ -676,19 +676,32 @@ export async function processDocumentsWithTrigger(
try {
logger.info(`[${requestId}] Triggering background processing for ${documents.length} documents`)
const jobPromises = documents.map(async (document) => {
const job = await tasks.trigger('knowledge-process-document', document)
return job.id
})
const MAX_BATCH_SIZE = 1000
const batchIds: string[] = []
const jobIds = await Promise.all(jobPromises)
for (let i = 0; i < documents.length; i += MAX_BATCH_SIZE) {
const chunk = documents.slice(i, i + MAX_BATCH_SIZE)
const batchResult = await tasks.batchTrigger(
'knowledge-process-document',
chunk.map((doc) => ({
payload: doc,
options: {
idempotencyKey: `doc-process-${doc.documentId}-${requestId}`,
tags: [`kb:${doc.knowledgeBaseId}`, `doc:${doc.documentId}`],
},
}))
)
batchIds.push(batchResult.batchId)
}
logger.info(`[${requestId}] Triggered ${jobIds.length} document processing jobs`)
logger.info(
`[${requestId}] Triggered ${documents.length} document processing jobs in ${batchIds.length} batch(es)`
)
return {
success: true,
message: `${documents.length} document processing jobs triggered`,
jobIds,
batchIds,
}
} catch (error) {
logger.error(`[${requestId}] Failed to trigger document processing jobs:`, error)
@@ -1590,10 +1603,19 @@ export async function retryDocumentProcessing(
chunkOverlap: kbConfig.overlap,
}
processDocumentAsync(knowledgeBaseId, documentId, docData, processingOptions).catch(
(error: unknown) => {
logger.error(`[${requestId}] Background retry processing error:`, error)
}
await processDocumentsWithQueue(
[
{
documentId,
filename: docData.filename,
fileUrl: docData.fileUrl,
fileSize: docData.fileSize,
mimeType: docData.mimeType,
},
],
knowledgeBaseId,
processingOptions,
requestId
)
logger.info(`[${requestId}] Document retry initiated: ${documentId}`)

View File

@@ -1,5 +1,4 @@
import isEqual from 'lodash/isEqual'
import omit from 'lodash/omit'
import { isEqual, omit } from 'es-toolkit'
import type { McpToolSchema, StoredMcpToolReference } from '@/lib/mcp/types'
export type McpToolIssueType =
@@ -33,8 +32,8 @@ export function hasSchemaChanged(
): boolean {
if (!storedSchema || !serverSchema) return false
const storedWithoutDesc = omit(storedSchema, 'description')
const serverWithoutDesc = omit(serverSchema, 'description')
const storedWithoutDesc = omit(storedSchema, ['description'])
const serverWithoutDesc = omit(serverSchema, ['description'])
return !isEqual(storedWithoutDesc, serverWithoutDesc)
}

View File

@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
import { toPng } from 'html-to-image'
const logger = createLogger('OGCapturePreview')
@@ -30,6 +29,7 @@ export async function captureWorkflowPreview(
try {
logger.info(`Capturing workflow preview for OG image (attempt ${attempt}/${retries})`)
const { toPng } = await import('html-to-image')
const dataUrl = await toPng(element, {
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,

View File

@@ -35,6 +35,7 @@ export interface TableSchema {
/** UI-only metadata stored alongside the table definition. */
export interface TableMetadata {
columnWidths?: Record<string, number>
columnOrder?: string[]
}
export interface TableDefinition {

View File

@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import {
type ExportWorkflowState,
sanitizeForExport,
@@ -9,6 +8,11 @@ import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowImportExport')
async function getJSZip() {
const { default: JSZip } = await import('jszip')
return JSZip
}
export interface WorkflowExportData {
workflow: {
id: string
@@ -138,6 +142,7 @@ export function exportWorkflowToJson(workflowData: WorkflowExportData): string {
* Workflows are placed at the root level (no folder structure).
*/
export async function exportWorkflowsToZip(workflows: WorkflowExportData[]): Promise<Blob> {
const JSZip = await getJSZip()
const zip = new JSZip()
const seenFilenames = new Set<string>()
@@ -182,6 +187,7 @@ export async function exportWorkspaceToZip(
folders: FolderExportData[],
workspaceColor?: string
): Promise<Blob> {
const JSZip = await getJSZip()
const zip = new JSZip()
const foldersMap = new Map(folders.map((f) => [f.id, f]))
@@ -244,6 +250,7 @@ export async function exportFolderToZip(
workflows: WorkflowExportData[],
folders: FolderExportData[]
): Promise<Blob> {
const JSZip = await getJSZip()
const zip = new JSZip()
const foldersMap = new Map(folders.map((f) => [f.id, f]))
@@ -317,6 +324,7 @@ function extractSortOrder(content: string): number | undefined {
export async function extractWorkflowsFromZip(
zipFile: File
): Promise<{ workflows: ImportedWorkflow[]; metadata?: WorkspaceImportMetadata }> {
const JSZip = await getJSZip()
const zip = await JSZip.loadAsync(await zipFile.arrayBuffer())
const workflows: ImportedWorkflow[] = []
let metadata: WorkspaceImportMetadata | undefined

View File

@@ -97,6 +97,25 @@ const nextConfig: NextConfig = {
turbopackSourceMaps: false,
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
optimizePackageImports: [
'lucide-react',
'lodash',
'framer-motion',
'reactflow',
'@radix-ui/react-dialog',
'@radix-ui/react-dropdown-menu',
'@radix-ui/react-popover',
'@radix-ui/react-select',
'@radix-ui/react-tabs',
'@radix-ui/react-tooltip',
'@radix-ui/react-accordion',
'@radix-ui/react-checkbox',
'@radix-ui/react-switch',
'@radix-ui/react-slider',
'react-markdown',
'zod',
'date-fns',
],
},
...(isDev && {
allowedDevOrigins: [

View File

@@ -106,6 +106,7 @@
"drizzle-orm": "^0.44.5",
"encoding": "0.1.13",
"entities": "6.0.1",
"es-toolkit": "1.45.1",
"ffmpeg-static": "5.3.0",
"fluent-ffmpeg": "2.1.3",
"framer-motion": "^12.5.0",
@@ -126,7 +127,6 @@
"json5": "2.2.3",
"jszip": "3.10.1",
"jwt-decode": "^4.0.0",
"lodash": "4.17.21",
"lucide-react": "^0.479.0",
"mammoth": "^1.9.0",
"marked": "17.0.4",
@@ -154,6 +154,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.2",
"react-joyride": "2.9.3",
"react-markdown": "^10.1.0",
"react-simple-code-editor": "^0.14.1",
"react-window": "2.2.3",
@@ -193,7 +194,6 @@
"@types/html-to-text": "9.0.4",
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.7",
"@types/lodash": "^4.17.16",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
"@types/nodemailer": "7.0.4",
@@ -227,6 +227,10 @@
"next": "16.1.6",
"@next/env": "16.1.6",
"drizzle-orm": "^0.44.5",
"postgres": "^3.4.5"
"postgres": "^3.4.5",
"react-floater": {
"react": "$react",
"react-dom": "$react-dom"
}
}
}

View File

@@ -36,11 +36,11 @@ function handleRootPathRedirects(
}
// For root path, redirect authenticated users to workspace
// Unless they have a 'from' query parameter (e.g., ?from=nav, ?from=settings)
// Unless they have a 'home' query parameter (e.g., ?home)
// This allows intentional navigation to the homepage from anywhere in the app
if (hasActiveSession) {
const from = url.searchParams.get('from')
if (!from) {
const isBrowsingHome = url.searchParams.has('home')
if (!isBrowsingHome) {
return NextResponse.redirect(new URL('/workspace', request.url))
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -180,6 +180,10 @@ export default {
from: { opacity: '0', transform: 'translateY(20px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
'tour-tooltip-in': {
from: { opacity: '0', transform: 'scale(0.96) translateY(4px)' },
to: { opacity: '1', transform: 'scale(1) translateY(0)' },
},
},
animation: {
'caret-blink': 'caret-blink 1.25s ease-out infinite',
@@ -193,6 +197,7 @@ export default {
'thinking-block': 'thinking-block 1.6s ease-in-out infinite',
'slide-in-right': 'slide-in-right 350ms ease-out forwards',
'slide-in-bottom': 'slide-in-bottom 400ms cubic-bezier(0.16, 1, 0.3, 1)',
'tour-tooltip-in': 'tour-tooltip-in 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94)',
},
},
},

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