diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx new file mode 100644 index 0000000000..1d7ad4f2f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -0,0 +1,455 @@ +'use client' + +import { type ComponentType, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + Badge, + Chip, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + InfoCard, + InfoCardItem, + InfoCardList, +} from '@/components/emcn' +import { useSession } from '@/lib/auth/auth-client' +import type { OAuthReturnContext } from '@/lib/credentials/client-state' +import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' +import { + getProviderIdFromServiceId, + OAUTH_PROVIDERS, + type OAuthProvider, + parseProvider, +} from '@/lib/oauth' +import { getScopeDescription } from '@/lib/oauth/utils' +import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections' + +const logger = createLogger('ConnectOAuthModal') + +/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */ +const DISPLAY_NAME_MAX_LENGTH = 255 + +/** + * Reserved tail budget when truncating the username so the auto-numbering + * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}. + */ +const COLLISION_SUFFIX_RESERVATION = 5 + +/** Upper bound for the auto-numbering search — pathological if ever reached. */ +const MAX_COLLISION_INDEX = 10000 + +const EMPTY_SCOPES: readonly string[] = [] + +type ServiceIcon = ComponentType<{ className?: string }> + +/** Scopes hidden from the permissions list — always present on Google flows. */ +function isHiddenScope(scope: string): boolean { + return scope.includes('userinfo.email') || scope.includes('userinfo.profile') +} + +/** + * Default credential display name. Produces `"{Name}'s {Service}"` when the + * user's name is known, falling back to `"My {Service}"` otherwise. The + * username is truncated so the full string (including any auto-numbering + * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}. + * + * When the base name collides with an existing credential in `takenNames`, + * `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison + * is case-insensitive to match the duplicate-detection used elsewhere in the + * modal. + */ +function defaultDisplayName( + userName: string | null | undefined, + serviceName: string, + takenNames: ReadonlySet +): string { + const trimmed = userName?.trim() + let base: string + if (trimmed) { + const suffix = `'s ${serviceName}` + const nameBudget = Math.max( + 0, + DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION + ) + const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed + base = `${safeName}${suffix}` + } else { + base = `My ${serviceName}` + } + + if (!takenNames.has(base.toLowerCase())) return base + for (let n = 2; n < MAX_COLLISION_INDEX; n++) { + const candidate = `${base} ${n}` + if (!takenNames.has(candidate.toLowerCase())) return candidate + } + return base +} + +/** + * Resolves the display name + icon for an OAuth `provider`/`serviceId` pair, + * preferring the most specific service entry and falling back to the base + * provider config, then to the raw provider id. Used when the caller does not + * supply explicit `serviceName`/`serviceIcon`. + */ +function resolveService( + provider: OAuthProvider, + serviceId: string +): { providerName: string; ProviderIcon: ServiceIcon } { + const { baseProvider } = parseProvider(provider) + const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] + let providerName = baseProviderConfig?.name || provider + let ProviderIcon: ServiceIcon = baseProviderConfig?.icon || (() => null) + if (baseProviderConfig) { + for (const [key, service] of Object.entries(baseProviderConfig.services)) { + if (key === serviceId || service.providerId === provider) { + providerName = service.name + ProviderIcon = service.icon + break + } + } + } + return { providerName, ProviderIcon } +} + +interface ConnectOAuthModalBaseProps { + open: boolean + onOpenChange: (open: boolean) => void + /** + * Canonical provider id (e.g. `google-email`). When omitted it is derived + * from `serviceId`. Used for the credential draft and return context. + */ + providerId?: string + /** + * Optional explicit display name/icon. When omitted, both are resolved from + * `provider` + `serviceId`. The integrations catalog supplies these directly; + * workflow/KB callers rely on resolution. + */ + serviceName?: string + serviceIcon?: ServiceIcon + /** Used to resolve display metadata and the provider id when not supplied directly. */ + provider?: OAuthProvider + serviceId?: string +} + +/** + * Connect mode. Creates the credential draft and writes the origin-specific + * OAuth return context before handing off to the provider via + * {@link useConnectOAuthService}. + */ +type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & { + mode: 'connect' + workspaceId: string + requiredScopes: readonly string[] +} & ( + | { origin: 'workflow'; workflowId: string } + | { origin: 'kb-connectors'; knowledgeBaseId: string; connectorType?: string } + | { origin: 'integrations' } + ) + +/** + * Reauthorize mode. Updates the scopes on an existing credential for + * `toolName`. `newScopes` are surfaced with a "New" badge. An optional + * `onConnect` override short-circuits the default provider hand-off. + */ +interface ConnectOAuthModalReauthorizeProps extends ConnectOAuthModalBaseProps { + mode: 'reauthorize' + toolName: string + requiredScopes?: readonly string[] + newScopes?: readonly string[] + onConnect?: () => Promise | void +} + +export type ConnectOAuthModalProps = + | ConnectOAuthModalConnectProps + | ConnectOAuthModalReauthorizeProps + +/** + * Unified connect/reauthorize OAuth credential modal (ChipModal UI). Mounted by + * the integrations catalog, the workflow editor's credential selectors, and the + * knowledge-base connector flows. After the redirect lands back on + * `window.location.href`, the host page's OAuth return router consumes the + * context written here. + */ +export function ConnectOAuthModal(props: ConnectOAuthModalProps) { + const { open, onOpenChange, mode } = props + const isConnect = mode === 'connect' + + const providerId = useMemo( + () => props.providerId ?? (props.serviceId ? getProviderIdFromServiceId(props.serviceId) : ''), + [props.providerId, props.serviceId] + ) + + const [displayName, setDisplayName] = useState('') + const [description, setDescription] = useState('') + const [validationError, setValidationError] = useState(null) + const [submitError, setSubmitError] = useState(null) + + const { data: session } = useSession() + const userName = session?.user?.name + + const { providerName, ProviderIcon } = useMemo(() => { + if (props.serviceName && props.serviceIcon) { + return { providerName: props.serviceName, ProviderIcon: props.serviceIcon } + } + const provider = (props.provider ?? providerId) as OAuthProvider + return resolveService(provider, props.serviceId ?? providerId) + }, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId]) + + const workspaceId = isConnect ? props.workspaceId : '' + const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ + workspaceId, + enabled: isConnect && Boolean(workspaceId) && open, + }) + const createDraft = useCreateCredentialDraft() + const connectOAuthService = useConnectOAuthService() + + /** + * Lowercased set of OAuth credential names already in the workspace. Drives + * both the prefill's auto-numbering and the inline duplicate-name error. + */ + const takenNames = useMemo( + () => + new Set( + credentials + .filter((credential) => credential.type === 'oauth') + .map((credential) => credential.displayName.toLowerCase()) + ), + [credentials] + ) + + const requiredScopes = props.requiredScopes ?? EMPTY_SCOPES + const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES + + const newScopesSet = useMemo( + () => new Set([...newScopes].filter((scope) => !isHiddenScope(scope))), + [newScopes] + ) + + const displayScopes = useMemo(() => { + const filtered = [...requiredScopes].filter((scope) => !isHiddenScope(scope)) + if (isConnect) return filtered + return filtered.sort((a, b) => { + const aIsNew = newScopesSet.has(a) + const bIsNew = newScopesSet.has(b) + if (aIsNew && !bIsNew) return -1 + if (!aIsNew && bIsNew) return 1 + return 0 + }) + }, [isConnect, requiredScopes, newScopesSet]) + + /** + * Initialize the connect form once per open session, after credentials have + * loaded so auto-numbering can see them. The `prefilled` ref ensures session + * refetches or other prop churn while the modal is open won't overwrite the + * user's typed value. + */ + const prefilled = useRef(false) + useEffect(() => { + if (!open) { + prefilled.current = false + return + } + if (!isConnect || prefilled.current || credentialsLoading) return + prefilled.current = true + setDisplayName(defaultDisplayName(userName, providerName, takenNames)) + setDescription('') + setValidationError(null) + setSubmitError(null) + }, [open, isConnect, credentialsLoading, userName, providerName, takenNames]) + + const existingCredential = useMemo(() => { + if (!isConnect) return null + const name = displayName.trim().toLowerCase() + if (!name || !takenNames.has(name)) return null + return ( + credentials.find((row) => row.type === 'oauth' && row.displayName.toLowerCase() === name) ?? + null + ) + }, [isConnect, credentials, displayName, takenNames]) + + const handleClose = () => { + setSubmitError(null) + onOpenChange(false) + } + + const handleConnect = async () => { + setValidationError(null) + setSubmitError(null) + try { + let connectorType: string | undefined + + if (isConnect) { + const trimmed = displayName.trim() + if (!trimmed) { + setValidationError('Display name is required.') + return + } + + await createDraft.mutateAsync({ + workspaceId, + providerId, + displayName: trimmed, + description: description.trim() || undefined, + }) + + const preCount = credentials.filter( + (c) => c.type === 'oauth' && c.providerId === providerId + ).length + + const baseContext = { + displayName: trimmed, + providerId, + preCount, + workspaceId, + requestedAt: Date.now(), + } + + let returnContext: OAuthReturnContext + if (props.origin === 'kb-connectors') { + connectorType = props.connectorType + returnContext = { + ...baseContext, + origin: 'kb-connectors', + knowledgeBaseId: props.knowledgeBaseId, + connectorType: props.connectorType, + } + } else if (props.origin === 'workflow') { + returnContext = { ...baseContext, origin: 'workflow', workflowId: props.workflowId } + } else { + returnContext = { ...baseContext, origin: 'integrations' } + } + + writeOAuthReturnContext(returnContext) + } else if (props.onConnect) { + await props.onConnect() + handleClose() + return + } else { + logger.info('Reauthorizing OAuth2', { + providerId, + requiredScopes, + hasNewScopes: newScopes.length > 0, + }) + } + + const callbackURL = new URL(window.location.href) + if (connectorType) { + callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType) + } + + await connectOAuthService.mutateAsync({ + providerId, + callbackURL: callbackURL.toString(), + }) + handleClose() + } catch (err: unknown) { + const message = getErrorMessage(err, 'Failed to start OAuth connection') + setSubmitError(message) + logger.error('Failed to connect OAuth service', err) + } + } + + const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending + const isDisabled = isConnect + ? !displayName.trim() || isPending || Boolean(existingCredential) + : isPending + + /** + * Submits the connect form on Enter, mirroring the Connect button's enabled + * state and excluding the multi-line description. Restores the keyboard + * affordance the pre-consolidation workflow modal provided. + */ + const handleBodyKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || !isConnect || isDisabled) return + if (event.target instanceof HTMLTextAreaElement) return + event.preventDefault() + void handleConnect() + } + + const displayNameError = + validationError ?? + (existingCredential + ? `An integration named "${existingCredential.displayName}" already exists.` + : undefined) + + const title = `Connect ${providerName}` + + return ( + + + {title} + + + {!isConnect && ( +

+ The "{props.toolName}" tool requires access to your account. +

+ )} + + {isConnect && ( + { + setDisplayName(value) + if (validationError) setValidationError(null) + }} + placeholder='Integration name' + autoComplete='off' + required + error={displayNameError} + /> + )} + + {isConnect && ( + + )} + + {displayScopes.length > 0 && ( + + + + {displayScopes.map((scope) => ( + + + {getScopeDescription(scope)} + {!isConnect && newScopesSet.has(scope) && ( + + New + + )} + + + ))} + + + + )} + + {submitError} +
+ + + Cancel + + + {isPending ? 'Connecting...' : 'Connect'} + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/index.ts new file mode 100644 index 0000000000..02665eada4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/index.ts @@ -0,0 +1,4 @@ +export { + ConnectOAuthModal, + type ConnectOAuthModalProps, +} from './connect-oauth-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/index.ts deleted file mode 100644 index 7842c2f04b..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getDefaultCredentialName, OAuthModal, type OAuthModalProps } from './oauth-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/oauth-modal.test.ts b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/oauth-modal.test.ts deleted file mode 100644 index d0ca2abfe6..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/oauth-modal.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/auth/auth-client', () => ({ - client: { oauth2: { link: vi.fn() } }, - useSession: vi.fn(() => ({ data: null, isPending: false, error: null })), -})) - -vi.mock('@/lib/credentials/client-state', () => ({ - writeOAuthReturnContext: vi.fn(), -})) - -vi.mock('@/hooks/queries/credentials', () => ({ - useCreateCredentialDraft: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), -})) - -vi.mock('@/lib/oauth', () => ({ - getCanonicalScopesForProvider: vi.fn(() => []), - getProviderIdFromServiceId: vi.fn((id: string) => id), - OAUTH_PROVIDERS: {}, - parseProvider: vi.fn((p: string) => ({ baseProvider: p, variant: null })), -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getScopeDescription: vi.fn((s: string) => s), -})) - -import { getDefaultCredentialName } from '@/app/workspace/[workspaceId]/components/oauth-modal' - -describe('getDefaultCredentialName', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('uses the user name when available', () => { - expect(getDefaultCredentialName('Waleed', 'Google Drive', 0)).toBe("Waleed's Google Drive 1") - }) - - it('increments the number based on existing credential count', () => { - expect(getDefaultCredentialName('Waleed', 'Google Drive', 2)).toBe("Waleed's Google Drive 3") - }) - - it('falls back to "My" when user name is null', () => { - expect(getDefaultCredentialName(null, 'Slack', 0)).toBe('My Slack 1') - }) - - it('falls back to "My" when user name is undefined', () => { - expect(getDefaultCredentialName(undefined, 'Gmail', 1)).toBe('My Gmail 2') - }) - - it('falls back to "My" when user name is empty string', () => { - expect(getDefaultCredentialName('', 'GitHub', 0)).toBe('My GitHub 1') - }) - - it('falls back to "My" when user name is whitespace-only', () => { - expect(getDefaultCredentialName(' ', 'Notion', 0)).toBe('My Notion 1') - }) - - it('trims whitespace from user name', () => { - expect(getDefaultCredentialName(' Waleed ', 'Linear', 0)).toBe("Waleed's Linear 1") - }) - - it('works with zero existing credentials', () => { - expect(getDefaultCredentialName('Alice', 'Jira', 0)).toBe("Alice's Jira 1") - }) - - it('works with many existing credentials', () => { - expect(getDefaultCredentialName('Bob', 'Slack', 9)).toBe("Bob's Slack 10") - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/oauth-modal.tsx deleted file mode 100644 index 4f02894879..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal/oauth-modal.tsx +++ /dev/null @@ -1,313 +0,0 @@ -'use client' - -import { useMemo, useState } from 'react' -import { createLogger } from '@sim/logger' -import { Check } from 'lucide-react' -import { - Badge, - Button, - Input, - Label, - Modal, - ModalBody, - ModalContent, - ModalDescription, - ModalFooter, - ModalHeader, -} from '@/components/emcn' -import { client, useSession } from '@/lib/auth/auth-client' -import type { OAuthReturnContext } from '@/lib/credentials/client-state' -import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' -import { - getCanonicalScopesForProvider, - getProviderIdFromServiceId, - OAUTH_PROVIDERS, - type OAuthProvider, - parseProvider, -} from '@/lib/oauth' -import { getScopeDescription } from '@/lib/oauth/utils' -import { useCreateCredentialDraft } from '@/hooks/queries/credentials' - -const logger = createLogger('OAuthModal') -const EMPTY_SCOPES: string[] = [] - -/** - * Generates a default credential display name. - * Format: "{User}'s {Provider} {N}" or "My {Provider} {N}" when no user name is available. - */ -export function getDefaultCredentialName( - userName: string | null | undefined, - providerName: string, - credentialCount: number -): string { - const trimmed = userName?.trim() - const num = credentialCount + 1 - if (trimmed) { - return `${trimmed}'s ${providerName} ${num}` - } - return `My ${providerName} ${num}` -} - -interface OAuthModalBaseProps { - isOpen: boolean - onClose: () => void - provider: OAuthProvider - serviceId: string -} - -type OAuthModalConnectProps = OAuthModalBaseProps & { - mode: 'connect' - workspaceId: string - credentialCount: number -} & ( - | { workflowId: string; knowledgeBaseId?: never; connectorType?: never } - | { workflowId?: never; knowledgeBaseId: string; connectorType?: string } - ) - -interface OAuthModalReauthorizeProps extends OAuthModalBaseProps { - mode: 'reauthorize' - toolName: string - requiredScopes?: string[] - newScopes?: string[] - onConnect?: () => Promise | void -} - -export type OAuthModalProps = OAuthModalConnectProps | OAuthModalReauthorizeProps - -export function OAuthModal(props: OAuthModalProps) { - const { isOpen, onClose, provider, serviceId, mode } = props - - const isConnect = mode === 'connect' - const credentialCount = isConnect ? props.credentialCount : 0 - const workspaceId = isConnect ? props.workspaceId : '' - const workflowId = isConnect ? props.workflowId : undefined - const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined - const connectorType = isConnect ? props.connectorType : undefined - const toolName = !isConnect ? props.toolName : '' - const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES - const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES - const onConnectOverride = !isConnect ? props.onConnect : undefined - - const { data: session } = useSession() - const [error, setError] = useState(null) - const createDraft = useCreateCredentialDraft() - - const { providerName, ProviderIcon } = useMemo(() => { - const { baseProvider } = parseProvider(provider) - const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] - let name = baseProviderConfig?.name || provider - let Icon = baseProviderConfig?.icon || (() => null) - if (baseProviderConfig) { - for (const [key, service] of Object.entries(baseProviderConfig.services)) { - if (key === serviceId || service.providerId === provider) { - name = service.name - Icon = service.icon - break - } - } - } - return { providerName: name, ProviderIcon: Icon } - }, [provider, serviceId]) - - const providerId = getProviderIdFromServiceId(serviceId) - - const [displayName, setDisplayName] = useState(() => - isConnect ? getDefaultCredentialName(session?.user?.name, providerName, credentialCount) : '' - ) - - const newScopesSet = useMemo( - () => - new Set( - newScopes.filter( - (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile') - ) - ), - [newScopes] - ) - - const displayScopes = useMemo(() => { - if (isConnect) { - return getCanonicalScopesForProvider(providerId).filter( - (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile') - ) - } - const filtered = requiredScopes.filter( - (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile') - ) - return filtered.sort((a, b) => { - const aIsNew = newScopesSet.has(a) - const bIsNew = newScopesSet.has(b) - if (aIsNew && !bIsNew) return -1 - if (!aIsNew && bIsNew) return 1 - return 0 - }) - }, [isConnect, providerId, requiredScopes, newScopesSet]) - - const handleClose = () => { - setError(null) - onClose() - } - - const handleConnect = async () => { - setError(null) - - try { - if (isConnect) { - const trimmedName = displayName.trim() - if (!trimmedName) { - setError('Display name is required.') - return - } - - await createDraft.mutateAsync({ - workspaceId, - providerId, - displayName: trimmedName, - }) - - const baseContext = { - displayName: trimmedName, - providerId, - preCount: credentialCount, - workspaceId, - requestedAt: Date.now(), - } - - const returnContext: OAuthReturnContext = knowledgeBaseId - ? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType } - : { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! } - - writeOAuthReturnContext(returnContext) - } - - if (!isConnect && onConnectOverride) { - await onConnectOverride() - onClose() - return - } - - if (!isConnect) { - logger.info('Linking OAuth2:', { - providerId, - requiredScopes, - hasNewScopes: newScopes.length > 0, - }) - } - - if (providerId === 'trello') { - if (!isConnect) onClose() - window.location.href = '/api/auth/trello/authorize' - return - } - - if (providerId === 'shopify') { - if (!isConnect) onClose() - const returnUrl = encodeURIComponent(window.location.href) - window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}` - return - } - - const callbackURL = new URL(window.location.href) - if (connectorType) { - callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType) - } - await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() }) - handleClose() - } catch (err) { - logger.error('Failed to initiate OAuth connection', { error: err }) - setError('Failed to connect. Please try again.') - } - } - - const isPending = isConnect && createDraft.isPending - const isConnectDisabled = isConnect ? !displayName.trim() || Boolean(isPending) : false - - const subtitle = isConnect - ? `Grant access to use ${providerName} in your ${knowledgeBaseId ? 'knowledge base' : 'workflow'}` - : `The "${toolName}" tool requires access to your account` - - return ( - !open && handleClose()}> - - Connect {providerName} - - - Connect your {providerName} account to grant access - -
-
-
- -
-
-

- Connect your {providerName} account -

-

{subtitle}

-
-
- - {displayScopes.length > 0 && ( -
-
-

- Permissions requested -

-
-
    - {displayScopes.map((scope) => ( -
  • -
    - -
    -
    - {getScopeDescription(scope)} - {!isConnect && newScopesSet.has(scope) && ( - - New - - )} -
    -
  • - ))} -
-
- )} - - {isConnect && ( -
- - { - setDisplayName(e.target.value) - setError(null) - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && !isPending) void handleConnect() - }} - placeholder={`My ${providerName} account`} - autoComplete='off' - data-lpignore='true' - className='mt-1.5' - /> -
- )} - - {error &&

{error}

} -
-
- - - - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts new file mode 100644 index 0000000000..f568d2dec2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts @@ -0,0 +1 @@ +export { WorkspaceChrome } from './workspace-chrome' diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx new file mode 100644 index 0000000000..4ac995e92b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useEffect } from 'react' +import { usePathname } from 'next/navigation' +import { cn } from '@/lib/core/utils/cn' +import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { useFullscreenOriginStore } from '@/stores/fullscreen-origin' + +const FULLSCREEN_SUFFIXES = ['/upgrade'] as const + +/** Slide timing for the fullscreen sidebar collapse and content shift. */ +const SLIDE_TRANSITION = + 'duration-[175ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none' + +interface WorkspaceChromeProps { + children: React.ReactNode +} + +function isFullscreenPath(pathname: string | null): boolean { + return FULLSCREEN_SUFFIXES.some((s) => pathname?.endsWith(s)) +} + +/** + * Renders the workspace chrome as a single persistent tree. The sidebar is + * always mounted; on a fullscreen route (`/upgrade`) its wrapper collapses to + * zero width while the inner shell slides off the left edge, revealing the route + * content. Because this component lives in the workspace layout it persists + * across navigations, so the pathname-driven class toggle animates smoothly. + * + * Leaving a fullscreen route is instant: App Router swaps `children` to the + * origin page and the fullscreen page is simply unmounted, while the sidebar + * slides back in. There is no exit fade — the new page just loads in place. + * + * Because the chrome observes every pathname transition, it records the page a + * fullscreen route was launched from into {@link useFullscreenOriginStore}. The + * route's Back control reads that origin to return deterministically, so any + * trigger that merely pushes a fullscreen route gets correct return-to-origin + * without per-call-site wiring. + * + * On a direct load of a fullscreen route the wrapper mounts already collapsed, + * so no slide plays (CSS transitions don't run on mount). + */ +export function WorkspaceChrome({ children }: WorkspaceChromeProps) { + const pathname = usePathname() + const isFullscreen = isFullscreenPath(pathname) + + const setOrigin = useFullscreenOriginStore((s) => s.setOrigin) + + // Remember the last non-fullscreen page so a fullscreen route's Back control + // can return there, deterministically and for any trigger. + useEffect(() => { + if (pathname && !isFullscreen) setOrigin(pathname) + }, [pathname, isFullscreen, setOrigin]) + + return ( +
+
+
+ +
+
+
+
+ {children} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index 46ebc4e399..447336d2c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -6,6 +6,7 @@ import { Table as TableIcon, Task, } from '@/components/emcn/icons' +import { AgentSkillsIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { cn } from '@/lib/core/utils/cn' import { workflowBorderColor } from '@/lib/workspaces/colors' @@ -100,4 +101,8 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record null }, slash_command: { label: 'Command', renderIcon: () => null }, integration: { label: 'Integration', renderIcon: renderIntegrationTile }, + skill: { + label: 'Skill', + renderIcon: ({ className }) => , + }, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx index fb804257ab..c83411cb97 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx @@ -1,11 +1,14 @@ 'use client' +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useParams, useRouter } from 'next/navigation' import { Chip } from '@/components/emcn' import { Credit } from '@/components/emcn/icons' import { formatCredits } from '@/lib/billing/credits/conversion' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' -import { useSubscriptionData } from '@/hooks/queries/subscription' -import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { prefetchUpgradeBillingData, useSubscriptionData } from '@/hooks/queries/subscription' +import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace' export function CreditsChip() { if (!isBillingEnabled) return null @@ -15,14 +18,28 @@ export function CreditsChip() { function CreditsChipInner() { const { data, isLoading } = useSubscriptionData() - const { navigateToSettings } = useSettingsNavigation() + const router = useRouter() + const queryClient = useQueryClient() + const { workspaceId } = useParams<{ workspaceId: string }>() + + const upgradeHref = `/workspace/${workspaceId}/upgrade` + + // Warm the route bundle and the exact queries the Upgrade page gates on, so + // the click navigates into already-cached data instead of a blank, loading page. + const prefetchUpgrade = useCallback(() => { + router.prefetch(upgradeHref) + prefetchUpgradeBillingData(queryClient) + prefetchWorkspaceSettings(queryClient, workspaceId) + }, [router, queryClient, upgradeHref, workspaceId]) if (isLoading || !data?.data) return null return ( navigateToSettings({ section: 'subscription' })} + aria-label='Upgrade plan' + onClick={() => router.push(upgradeHref)} + onMouseEnter={prefetchUpgrade} + onFocus={prefetchUpgrade} leftIcon={Credit} > {formatCredits(data.data.creditBalance)} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx index 120c9b1770..3fc061a885 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx @@ -1,7 +1,7 @@ 'use client' import { type ComponentType, useEffect, useMemo, useState } from 'react' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn' import { Table } from '@/components/emcn/icons' import { @@ -16,8 +16,12 @@ import { SlackIcon, } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' -import { INTEGRATIONS } from '@/lib/integrations' -import { integrationConnectHref } from '@/app/workspace/[workspaceId]/integrations/connect-route' +import { + INTEGRATIONS, + type OAuthServiceMatch, + resolveOAuthServiceForSlug, +} from '@/lib/integrations' +import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections' @@ -222,8 +226,6 @@ interface SuggestedActionsProps { export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { const { workspaceId } = useParams<{ workspaceId: string }>() - const router = useRouter() - const [expanded, setExpanded] = useState(true) const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({ workspaceId, @@ -231,6 +233,19 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { }) const { data: services = EMPTY_SERVICES } = useOAuthConnections() + const [expanded, setExpanded] = useState(true) + // Collapsible animations are enabled only after the first user toggle, so the + // initially-open, server-rendered panel appears at full height on first paint + // instead of replaying the open animation and shifting the input above it. + const [animationsEnabled, setAnimationsEnabled] = useState(false) + const [actions, setActions] = useState(INITIAL_ACTIONS) + /** + * OAuth connect modal target. Setting this opens the modal; setting it back + * to `null` (via `onOpenChange(false)`) closes it. Mirrors the local-state + * pattern used by the integrations detail page. + */ + const [oauthTarget, setOAuthTarget] = useState(null) + const connectedProviders = useMemo( () => new Set( @@ -242,8 +257,6 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { [credentials] ) - const [actions, setActions] = useState(INITIAL_ACTIONS) - useEffect(() => { if (services.length === 0 || connectedProviders.size === 0) return @@ -273,14 +286,18 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) { onSelectPrompt(action.prompt) return } - router.push(integrationConnectHref(workspaceId, action.slug)) + const match = resolveOAuthServiceForSlug(action.slug) + if (match) setOAuthTarget(match) } return (
- +
{actions.map((action, i) => { const Icon = action.icon @@ -318,6 +335,21 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
+ {oauthTarget && workspaceId && ( + { + if (!open) setOAuthTarget(null) + }} + workspaceId={workspaceId} + providerId={oauthTarget.providerId} + requiredScopes={oauthTarget.requiredScopes} + serviceName={oauthTarget.serviceName} + serviceIcon={oauthTarget.serviceIcon} + /> + )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts new file mode 100644 index 0000000000..443c122289 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -0,0 +1,225 @@ +import { + computeMentionHighlightRanges, + extractContextTokens, + restoreSkillTriggerText, + stripMentionTrigger, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import type { ChatContext } from '@/stores/panel' + +/** URI scheme for portable chip links (`[label](sim:kind/id)`). Custom so only + * our own links — never generic markdown — are parsed back into chips. */ +const CHIP_LINK_SCHEME = 'sim' + +/** + * Every chip kind that carries a single stable identifier → the + * {@link ChatContext} id field encoded in `sim:/`. This is the one map + * that drives BOTH serialization and parsing, so every chip — resource, skill, + * integration, slash command — round-trips through the exact same mechanism by + * its true id (not by name). `satisfies Partial>` keeps it union-synced: rename a kind's id field and this stops + * type-checking. + * + * Excluded kinds (`current_workflow`, `blocks`, `workflow_block`, `docs`) carry + * no single portable id (an array / two ids / none) and degrade to plain text. + */ +const PORTABLE_KIND_TO_ID_FIELD = { + table: 'tableId', + file: 'fileId', + folder: 'folderId', + filefolder: 'fileFolderId', + knowledge: 'knowledgeId', + past_chat: 'chatId', + workflow: 'workflowId', + logs: 'executionId', + skill: 'skillId', + integration: 'blockType', + slash_command: 'command', +} as const satisfies Partial> + +/** + * The subset of {@link ChatContext} kinds that serialize to a portable + * `sim:/` markdown link. + */ +export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD + +/** + * Matches a portable chip markdown link: `[label](sim:kind/id)`. + * - group 1: label (any non-`]` chars) + * - group 2: kind (lowercase letters / underscores, e.g. `past_chat`) + * - group 3: id (any non-`)` / non-whitespace chars) + */ +const CHIP_LINK_PATTERN = new RegExp( + `\\[([^\\]]+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`, + 'g' +) + +/** + * Parsed result of a single portable chip markdown link, including the source + * span so callers can rewrite the surrounding text. + */ +export interface ParsedChipLink { + kind: PortableKind + id: string + label: string + start: number + end: number +} + +/** + * Type guard narrowing an arbitrary kind string to a {@link PortableKind}. + */ +function isPortableKind(kind: string): kind is PortableKind { + return Object.hasOwn(PORTABLE_KIND_TO_ID_FIELD, kind) +} + +/** + * Reads the portable id off a context for its kind, or `undefined` when the + * context isn't a portable kind. Centralizes the id-field lookup so the + * `PORTABLE_KIND_TO_ID_FIELD` map stays the single source of truth. + */ +function getPortableId(context: ChatContext): string | undefined { + if (!isPortableKind(context.kind)) return undefined + const field = PORTABLE_KIND_TO_ID_FIELD[context.kind] + const value = (context as Record)[field] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +/** + * Serializes a context to a portable `[label](sim:kind/id)` markdown link. + * + * @param context - The chat context to serialize. + * @returns The markdown link string, or `null` when the context isn't a + * portable kind or its id field is missing/empty. + */ +function serializeChipContext(context: ChatContext): string | null { + if (!isPortableKind(context.kind)) return null + const id = getPortableId(context) + if (!id) return null + return `[${context.label}](${CHIP_LINK_SCHEME}:${context.kind}/${id})` +} + +/** + * The textarea token to insert when re-creating a chip on paste. Delegates to + * {@link extractContextTokens} so the per-kind prefix (skill EM-SPACE sentinel, + * slash `/`, `@` for everything else) has a single source of truth. The `??` + * fallback is unreachable for portable contexts (they always have a label and + * are never `current_workflow`); it only satisfies the optional return type. + */ +export function chipDisplayToken(context: ChatContext): string { + return extractContextTokens([context])[0] ?? `@${context.label}` +} + +/** + * Serializes a selected slice of input text for the clipboard. + * + * Reuses the overlay's exact tokenization + * ({@link computeMentionHighlightRanges} over {@link extractContextTokens}) so + * a chip that renders as a highlighted token is the one that gets converted. + * Every portable chip — resource, skill, integration, slash command — becomes a + * `[label](sim:kind/id)` markdown link carrying its true id. Only non-portable + * tokens and the plain text around chips fall through {@link + * restoreSkillTriggerText} (mapping any stray skill sentinel back to `/`). + * + * When there is nothing to convert the output is byte-identical to + * `restoreSkillTriggerText(selectedText)`; for a selection with no skill + * sentinels that equals the raw selection, letting callers detect "no change". + * + * @param selectedText - The raw selected substring from the textarea. + * @param contexts - The currently selected contexts (mention sources). + * @returns The clipboard-ready string. + */ +export function serializeSelectionForClipboard( + selectedText: string, + contexts: ChatContext[] +): string { + const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts)) + if (ranges.length === 0) return restoreSkillTriggerText(selectedText) + + let result = '' + let lastIndex = 0 + + for (const range of ranges) { + if (range.start > lastIndex) { + result += restoreSkillTriggerText(selectedText.slice(lastIndex, range.start)) + } + + const label = stripMentionTrigger(range.token) + const matched = contexts.find((c) => c.label === label) + const serialized = matched ? serializeChipContext(matched) : null + result += serialized ?? restoreSkillTriggerText(range.token) + + lastIndex = range.end + } + + if (lastIndex < selectedText.length) { + result += restoreSkillTriggerText(selectedText.slice(lastIndex)) + } + + return result +} + +/** + * Parses all portable chip markdown links from a string, in source order. + * + * Pure string→data: never fetches or executes. Matches whose kind is not a + * {@link PortableKind} are skipped so non-portable `sim:` shapes don't leak + * into the chip pipeline. + * + * @param text - The text to scan (e.g. pasted clipboard content). + * @returns Parsed links with their `start`/`end` source spans. + */ +export function parseChipLinks(text: string): ParsedChipLink[] { + const links: ParsedChipLink[] = [] + const pattern = new RegExp(CHIP_LINK_PATTERN.source, 'g') + let match: RegExpExecArray | null + + while ((match = pattern.exec(text)) !== null) { + const [full, label, kind, id] = match + if (!isPortableKind(kind)) continue + links.push({ + kind, + id, + label, + start: match.index, + end: match.index + full.length, + }) + } + + return links +} + +/** + * Reconstructs the exact {@link ChatContext} shape for a parsed chip link. + * + * The `switch` over the literal kind narrows the return so each branch builds + * a fully-typed context with no cast. + * + * @param link - A link produced by {@link parseChipLinks}. + * @returns The matching chat context. + */ +export function chipLinkToContext(link: ParsedChipLink): ChatContext { + switch (link.kind) { + case 'table': + return { kind: 'table', tableId: link.id, label: link.label } + case 'file': + return { kind: 'file', fileId: link.id, label: link.label } + case 'folder': + return { kind: 'folder', folderId: link.id, label: link.label } + case 'filefolder': + return { kind: 'filefolder', fileFolderId: link.id, label: link.label } + case 'knowledge': + return { kind: 'knowledge', knowledgeId: link.id, label: link.label } + case 'past_chat': + return { kind: 'past_chat', chatId: link.id, label: link.label } + case 'workflow': + return { kind: 'workflow', workflowId: link.id, label: link.label } + case 'logs': + return { kind: 'logs', executionId: link.id, label: link.label } + case 'skill': + return { kind: 'skill', skillId: link.id, label: link.label } + case 'integration': + return { kind: 'integration', blockType: link.id, label: link.label } + case 'slash_command': + return { kind: 'slash_command', command: link.id, label: link.label } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index a0e71aee02..9b1c2d4d3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -1,5 +1,12 @@ export { AnimatedPlaceholderEffect } from './animated-placeholder-effect' export { AttachedFilesList } from './attached-files-list' +export type { ParsedChipLink, PortableKind } from './chip-clipboard-codec' +export { + chipDisplayToken, + chipLinkToContext, + parseChipLinks, + serializeSelectionForClipboard, +} from './chip-clipboard-codec' export type { PlusMenuHandle, SpeechRecognitionErrorEvent, @@ -20,3 +27,5 @@ export { MicButton } from './mic-button' export type { AvailableResourceGroup } from './plus-menu-dropdown' export { PlusMenuDropdown } from './plus-menu-dropdown' export { SendButton } from './send-button' +export type { SkillsMenuHandle } from './skills-menu-dropdown/skills-menu-dropdown' +export { SkillsMenuDropdown } from './skills-menu-dropdown/skills-menu-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index a226b153c4..7cc83b3899 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -22,10 +22,20 @@ import { } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' -import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' export type AvailableResourceGroup = ReturnType[number] +/** + * Resource types that are only offered via `@`-mention autocomplete and hidden + * from the `+` browse menu. Integrations are searchable inline (e.g. typing + * `@sla` surfaces Slack) but should not clutter the explicit attach menu. + */ +const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration']) + interface PlusMenuDropdownProps { availableResources: AvailableResourceGroup[] onResourceSelect: (resource: MothershipResource) => void @@ -71,17 +81,27 @@ export const PlusMenuDropdown = React.memo( setOpen(false) }, []) + // The `+` browse menu hides mention-only resource types; `@`-mention mode + // exposes the full catalog so integrations remain searchable inline. + const visibleResources = useMemo( + () => + isMention + ? availableResources + : availableResources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)), + [isMention, availableResources] + ) + const workflowTree = useMemo(() => { - const workflowGroup = availableResources.find((g) => g.type === 'workflow') - const folderGroup = availableResources.find((g) => g.type === 'folder') + const workflowGroup = visibleResources.find((g) => g.type === 'workflow') + const folderGroup = visibleResources.find((g) => g.type === 'folder') return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) - }, [availableResources]) + }, [visibleResources]) const fileFolderTree = useMemo(() => { - const fileGroup = availableResources.find((g) => g.type === 'file') - const fileFolderGroup = availableResources.find((g) => g.type === 'filefolder') + const fileGroup = visibleResources.find((g) => g.type === 'file') + const fileFolderGroup = visibleResources.find((g) => g.type === 'filefolder') return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? []) - }, [availableResources]) + }, [visibleResources]) const filteredItems = useMemo(() => { const rawQuery = isMention ? (mentionQuery ?? '') : search @@ -89,14 +109,12 @@ export const PlusMenuDropdown = React.memo( // In mention mode always render a flat filtered list — empty query = show everything. if (!isMention && !q) return null if (isMention && !q) { - return availableResources.flatMap(({ type, items }) => - items.map((item) => ({ type, item })) - ) + return visibleResources.flatMap(({ type, items }) => items.map((item) => ({ type, item }))) } - return availableResources.flatMap(({ type, items }) => + return visibleResources.flatMap(({ type, items }) => items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) ) - }, [isMention, mentionQuery, search, availableResources]) + }, [isMention, mentionQuery, search, visibleResources]) const filteredItemsRef = useRef(filteredItems) filteredItemsRef.current = filteredItems @@ -313,7 +331,7 @@ export const PlusMenuDropdown = React.memo( )} - {availableResources + {visibleResources .filter( ({ type }) => type !== 'workflow' && diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx new file mode 100644 index 0000000000..c5e586b850 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx @@ -0,0 +1,214 @@ +'use client' + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/emcn' +import { AgentSkillsIcon } from '@/components/icons' +import { cn } from '@/lib/core/utils/cn' +import type { SkillDefinition } from '@/hooks/queries/skills' + +/** + * Imperative handle for driving the skills menu from the host textarea's + * keyboard handler. Mirrors the shape of `PlusMenuHandle` but exposes only + * the operations the slash-trigger flow needs. + */ +export interface SkillsMenuHandle { + /** Opens the menu, optionally anchored at a caret position. */ + open: (anchor?: { left: number; top: number }) => void + /** Closes the menu. */ + close: () => void + /** Moves the active highlight by `delta` rows (wrapping). */ + moveActive: (delta: number) => void + /** Selects the active row. Returns true when a skill was selected. */ + selectActive: () => boolean +} + +interface SkillsMenuDropdownProps { + /** Skills available in the current workspace. */ + skills: SkillDefinition[] + /** Called when a skill row is chosen (click / keyboard). */ + onSkillSelect: (skill: SkillDefinition) => void + /** Called when the menu closes so the host can reset slash state. */ + onClose: () => void + /** Host textarea — focus is restored to it on close. */ + textareaRef: React.RefObject + /** Shared caret position restored after the dormant focus trap closes. */ + pendingCursorRef: React.MutableRefObject + /** Active `/`-query used to filter the list (case-insensitive substring). */ + slashQuery?: string +} + +/** + * Floating autocomplete list of workspace skills, anchored at the caret. It + * mirrors the anchored-trigger + dormant-focus-trap pattern of + * `PlusMenuDropdown` so the textarea keeps focus and typing continues + * uninterrupted while the user navigates skills with the keyboard. + */ +export const SkillsMenuDropdown = React.memo( + React.forwardRef(function SkillsMenuDropdown( + { skills, onSkillSelect, onClose, textareaRef, pendingCursorRef, slashQuery }, + ref + ) { + const [open, setOpen] = useState(false) + const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) + const [activeIndex, setActiveIndex] = useState(0) + const contentRef = useRef(null) + + const filteredSkills = useMemo(() => { + const q = (slashQuery ?? '').toLowerCase().trim() + if (!q) return skills + return skills.filter((skill) => skill.name.toLowerCase().includes(q)) + }, [skills, slashQuery]) + + const filteredSkillsRef = useRef(filteredSkills) + filteredSkillsRef.current = filteredSkills + const activeIndexRef = useRef(activeIndex) + activeIndexRef.current = activeIndex + + const doOpen = useCallback((anchor?: { left: number; top: number }) => { + if (anchor) setAnchorPos(anchor) + setOpen(true) + setActiveIndex(0) + }, []) + + const doClose = useCallback(() => { + setOpen(false) + }, []) + + const handleSelect = useCallback( + (skill: SkillDefinition) => { + onSkillSelect(skill) + setOpen(false) + setActiveIndex(0) + }, + [onSkillSelect] + ) + + const handleSelectRef = useRef(handleSelect) + handleSelectRef.current = handleSelect + + React.useImperativeHandle( + ref, + () => ({ + open: doOpen, + close: doClose, + moveActive: (delta: number) => { + const items = filteredSkillsRef.current + if (items.length === 0) return + setActiveIndex((i) => { + const next = i + delta + if (next < 0) return items.length - 1 + if (next >= items.length) return 0 + return next + }) + }, + selectActive: () => { + const items = filteredSkillsRef.current + if (items.length === 0) return false + const target = items[activeIndexRef.current] ?? items[0] + if (!target) return false + handleSelectRef.current(target) + return true + }, + }), + [doOpen, doClose] + ) + + // Reset highlight to the top whenever the query changes so the best match + // is always selected as the user types. + useEffect(() => { + setActiveIndex(0) + }, [slashQuery]) + + // Sync DOM scroll to the keyboard-highlighted row. + useEffect(() => { + if (filteredSkills.length === 0) return + const row = contentRef.current?.querySelector( + `[data-filtered-idx="${activeIndex}"]` + ) + row?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, filteredSkills]) + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + setAnchorPos(null) + setActiveIndex(0) + onClose() + } + } + + const handleCloseAutoFocus = (e: Event) => { + e.preventDefault() + const textarea = textareaRef.current + if (!textarea) return + if (pendingCursorRef.current !== null) { + textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current) + pendingCursorRef.current = null + } + textarea.focus() + } + + // Preventing the mount auto-focus keeps the textarea focused and leaves the + // Radix focus trap dormant, so typing continues uninterrupted. + const handleOpenAutoFocus = (e: Event) => { + e.preventDefault() + } + + return ( + + +
+ + +
+ {filteredSkills.length > 0 ? ( + filteredSkills.map((skill, index) => { + const isActive = index === activeIndex + return ( + + ) + }) + ) : ( +
+ No skills +
+ )} +
+
+ + ) + }) +) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts new file mode 100644 index 0000000000..8a962686df --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts @@ -0,0 +1,188 @@ +import { useCallback, useMemo, useRef } from 'react' +import { + escapeRegex, + SKILL_CHIP_TRIGGER, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import type { SkillDefinition } from '@/hooks/queries/skills' +import type { ChatContext } from '@/stores/panel' + +/** + * Characters that signal the user has completed a word. Used by the + * keystroke fast-path to detect that a typed skill name just ended. Mirrors + * the integration auto-mention boundary set so the two detectors behave + * symmetrically. + */ +const WORD_BOUNDARY_REGEX = /^[\s.,;:!?(){}[\]"'`/\\<>\n]$/ + +type SkillContext = Extract + +/** + * A skill trigger — the typed `/` or the stored EM SPACE sentinel — only counts + * when it itself starts a token (position 0 or after whitespace). This prevents + * path-like patterns (`foo/bar`) from chipping as `/bar`, and lets a pasted or + * restored `name` token re-chip just like a freshly typed `/name`. + */ +function isTriggerPrefixAt(text: string, index: number): boolean { + const ch = text[index] + if (ch !== '/' && ch !== SKILL_CHIP_TRIGGER) return false + if (index === 0) return true + return /\s/.test(text[index - 1]) +} + +interface UseSkillAutoMentionProps { + /** Skills available in the current workspace. */ + skills: SkillDefinition[] + /** Setter for the host's selected contexts. */ + setSelectedContexts: React.Dispatch> +} + +interface ProcessChangeArgs { + textarea: HTMLTextAreaElement + previousValue: string + nextValue: string +} + +/** + * Auto-registers skill contexts when a typed `/skill-name` is completed. + * + * The user types `/skill-name`, but the displayed token stores an EM SPACE + * sentinel (`SKILL_CHIP_TRIGGER`) in place of the narrow `/` so the centered + * chip icon fits its overlay slot like `@` does. Both entry points accept + * either trigger — a freshly typed `/` or a pasted/restored sentinel — swap a + * typed `/` for the sentinel, and share one dedup-by-skillId helper: + * - `processChange`: keystroke fast-path. When a word-boundary char completes + * a `/name` token whose name matches a known skill, the leading `/` is + * swapped for the sentinel (caret preserved) and the skill context is + * registered. + * - `applyToText`: bulk path for paste, template insertion, draft restore, and + * speech-to-text. Swaps each matched typed `/` for the sentinel in the + * returned string and registers any matched skill contexts. + */ +export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAutoMentionProps) { + /** + * Matcher built from skill names, longest-first so `/my-skill-extended` + * wins over `/my-skill`. The trailing guard rejects partial matches that + * continue into more name characters. + */ + const matcher = useMemo(() => { + const byName = new Map() + for (const skill of skills) { + byName.set(skill.name.toLowerCase(), { + kind: 'skill', + skillId: skill.id, + label: skill.name, + }) + } + const names = [...skills].map((s) => s.name).sort((a, b) => b.length - a.length) + if (names.length === 0) return { regex: null as RegExp | null, byName } + // Match either trigger: the typed '/' or the stored sentinel, so both fresh + // input and pasted/restored chips resolve. The trigger group is the match's + // first char (`text[match.index]`); group 1 is the skill name. + const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})` + const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])` + return { regex: new RegExp(pattern, 'gi'), byName } + }, [skills]) + + const matcherRef = useRef(matcher) + matcherRef.current = matcher + + const mergeContexts = useCallback( + (additions: SkillContext[]) => { + if (additions.length === 0) return + setSelectedContexts((prev) => { + const existing = new Set( + prev.filter((c): c is SkillContext => c.kind === 'skill').map((c) => c.skillId) + ) + const fresh = additions.filter((c) => !existing.has(c.skillId)) + return fresh.length > 0 ? [...prev, ...fresh] : prev + }) + }, + [setSelectedContexts] + ) + + const processChange = useCallback( + ({ textarea, previousValue, nextValue }: ProcessChangeArgs): string => { + if (nextValue.length !== previousValue.length + 1) return nextValue + const { regex, byName } = matcherRef.current + if (!regex) return nextValue + + let diffIndex = 0 + while ( + diffIndex < previousValue.length && + previousValue[diffIndex] === nextValue[diffIndex] + ) { + diffIndex++ + } + + const inserted = nextValue[diffIndex] + if (!inserted || !WORD_BOUNDARY_REGEX.test(inserted)) return nextValue + + const before = nextValue.slice(0, diffIndex) + regex.lastIndex = 0 + let completed: { start: number; name: string } | null = null + let match: RegExpExecArray | null + while ((match = regex.exec(before)) !== null) { + if (match.index + match[0].length === before.length) { + completed = { start: match.index, name: match[1] } + } + } + if (!completed) return nextValue + if (!isTriggerPrefixAt(nextValue, completed.start)) return nextValue + + const context = byName.get(completed.name.toLowerCase()) + if (!context) return nextValue + + mergeContexts([context]) + + // A typed '/' becomes the wide sentinel so the centered chip icon fits its + // overlay slot; a sentinel that's already there (e.g. just-pasted) is left + // as-is. `setRangeText` with 'preserve' keeps the caret and folds into the + // keystroke's native undo step; the returned value mirrors the rewrite so + // the controlled state updates too. + if (nextValue[completed.start] !== '/') return nextValue + textarea.setRangeText(SKILL_CHIP_TRIGGER, completed.start, completed.start + 1, 'preserve') + return textarea.value + }, + [matcherRef, mergeContexts] + ) + + const applyToText = useCallback( + (text: string): string => { + const { regex, byName } = matcherRef.current + if (!regex || !text) return text + + regex.lastIndex = 0 + const additions: SkillContext[] = [] + const seen = new Set() + const slashIndices: number[] = [] + let match: RegExpExecArray | null + while ((match = regex.exec(text)) !== null) { + const index = match.index + if (!isTriggerPrefixAt(text, index)) continue + const context = byName.get(match[1].toLowerCase()) + if (!context) continue + // Rewrite every confirmed typed '/' (even a repeated skill) so duplicate + // tokens chip consistently; tokens already on the sentinel need no edit. + if (text[index] === '/') slashIndices.push(index) + if (seen.has(context.skillId)) continue + seen.add(context.skillId) + additions.push(context) + } + mergeContexts(additions) + + if (slashIndices.length === 0) return text + // Replace each matched '/' with the wide sentinel so paste, draft, and STT + // paths chip correctly. Splice by UTF-16 index (the regex's index space) + // descending so earlier replacements don't shift later indices; the + // sentinel is one code unit wide like '/'. + let result = text + for (const idx of [...slashIndices].sort((a, b) => b - a)) { + result = result.slice(0, idx) + SKILL_CHIP_TRIGGER + result.slice(idx + 1) + } + return result + }, + [matcherRef, mergeContexts] + ) + + return { processChange, applyToText } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 6053bc992a..202e981821 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -13,28 +13,36 @@ import { } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' -import { Button, Paperclip, Tooltip } from '@/components/emcn' -import { useSession } from '@/lib/auth/auth-client' +import { Button, Paperclip, Slash, Tooltip } from '@/components/emcn' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { cn } from '@/lib/core/utils/cn' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' -import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components' +import type { + PlusMenuHandle, + SkillsMenuHandle, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { AnimatedPlaceholderEffect, AttachedFilesList, autoResizeTextarea, + chipDisplayToken, + chipLinkToContext, DropOverlay, MAX_CHAT_TEXTAREA_HEIGHT, MicButton, mapResourceToContext, OVERLAY_CLASSES, PlusMenuDropdown, + parseChipLinks, SendButton, + SkillsMenuDropdown, + serializeSelectionForClipboard, TEXTAREA_BASE_CLASSES, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' +import { useSkillAutoMention } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention' import type { FileAttachmentForApi, MothershipResource, @@ -51,7 +59,11 @@ import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/ import { computeMentionHighlightRanges, extractContextTokens, + restoreSkillTriggerText, + SKILL_CHIP_TRIGGER, + stripMentionTrigger, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import { type SkillDefinition, useSkills } from '@/hooks/queries/skills' import { useWorkflowMap } from '@/hooks/queries/workflows' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useSpeechToText } from '@/hooks/use-speech-to-text' @@ -148,20 +160,22 @@ export const UserInput = forwardRef(function Us const { workspaceId } = useParams<{ workspaceId: string }>() const { navigateToSettings } = useSettingsNavigation() const { data: workflowsById = {} } = useWorkflowMap(workspaceId) - const { data: session } = useSession() - const [value, setValue] = useState(() => { - if (defaultValue) return defaultValue - if (!draftScopeKey) return '' - const text = useMothershipDraftsStore.getState().drafts[draftScopeKey]?.text - return typeof text === 'string' ? text : '' - }) + const { data: skills = [] } = useSkills(workspaceId) + // SSR-safe initial value. The server has no localStorage, so reading a + // persisted draft here would diverge from the client's synchronous + // zustand-persist rehydration and trigger a hydration mismatch (flash). + // Draft text is restored after mount in the effect below instead. + const [value, setValue] = useState(defaultValue) + const valueRef = useRef(value) + valueRef.current = value const overlayRef = useRef(null) const plusMenuRef = useRef(null) + const skillsMenuRef = useRef(null) const prevDefaultValueRef = useRef(defaultValue) const files = useFileAttachments({ - userId: userId || session?.user?.id, + userId, workspaceId, disabled: false, isLoading: isSending, @@ -220,6 +234,8 @@ export const UserInput = forwardRef(function Us if (restoredContexts) contextManagement.setSelectedContexts(restoredContexts) if (restoredFiles) files.restoreAttachedFiles(restoredFiles) if (caretText !== null) { + setValue(caretText) + valueRef.current = caretText const textarea = textareaRef.current if (textarea) { textarea.focus() @@ -313,17 +329,29 @@ export const UserInput = forwardRef(function Us selectedContexts: contextManagement.selectedContexts, mentionMenu, setMessage: setValue, - setSelectedContexts: contextManagement.setSelectedContexts, }) const integrationAutoMention = useIntegrationAutoMention({ setSelectedContexts: contextManagement.setSelectedContexts, }) - const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending && !hasUploadingFiles + const skillAutoMention = useSkillAutoMention({ + skills, + setSelectedContexts: contextManagement.setSelectedContexts, + }) - const valueRef = useRef(value) - valueRef.current = value + /** + * Bulk-chipifies a block of text on the non-keystroke paths (mount, template, + * draft restore, STT, queued message, multi-char paste): integration `@` + * names first (rewrites the text), then skill `/` triggers (swapped to the + * sentinel). Returns the fully converted text and registers both context kinds. + */ + const applyAutoMentions = useCallback( + (text: string) => skillAutoMention.applyToText(integrationAutoMention.applyToText(text)), + [skillAutoMention.applyToText, integrationAutoMention.applyToText] + ) + + const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending && !hasUploadingFiles /** * Convert integration names on mount for any initial value seeded by @@ -335,8 +363,9 @@ export const UserInput = forwardRef(function Us */ useEffect(() => { if (!valueRef.current) return - const converted = integrationAutoMention.applyToText(valueRef.current) - if (converted !== valueRef.current) setValue(converted) + const original = valueRef.current + const converted = applyAutoMentions(original) + if (converted !== original) setValue(converted) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -350,15 +379,15 @@ export const UserInput = forwardRef(function Us useEffect(() => { if (defaultValue === prevDefaultValueRef.current) return prevDefaultValueRef.current = defaultValue - if (defaultValue) setValue(integrationAutoMention.applyToText(defaultValue)) - }, [defaultValue, integrationAutoMention.applyToText]) + if (defaultValue) setValue(applyAutoMentions(defaultValue)) + }, [defaultValue, applyAutoMentions]) const sttPrefixRef = useRef('') function handleTranscript(text: string) { const prefix = sttPrefixRef.current const newVal = prefix ? `${prefix} ${text}` : text - const converted = integrationAutoMention.applyToText(newVal) + const converted = applyAutoMentions(newVal) setValue(converted) valueRef.current = converted } @@ -401,12 +430,15 @@ export const UserInput = forwardRef(function Us const pendingCursorRef = useRef(null) const mentionRangeRef = useRef<{ start: number; end: number } | null>(null) const [mentionQuery, setMentionQuery] = useState(null) + const slashRangeRef = useRef<{ start: number; end: number } | null>(null) + const [slashQuery, setSlashQuery] = useState(null) useImperativeHandle( ref, () => ({ loadQueuedMessage: (msg: QueuedMessage) => { - setValue(integrationAutoMention.applyToText(msg.content)) + const converted = applyAutoMentions(msg.content) + setValue(converted) const restored: AttachedFile[] = (msg.fileAttachments ?? []).map((a) => ({ id: a.id, name: a.filename, @@ -432,7 +464,7 @@ export const UserInput = forwardRef(function Us files.restoreAttachedFiles, contextManagement.setSelectedContexts, textareaRef, - integrationAutoMention.applyToText, + applyAutoMentions, ] ) @@ -489,6 +521,51 @@ export const UserInput = forwardRef(function Us [textareaRef, handleContextAdd] ) + const handleSkillSelect = useCallback( + (skill: SkillDefinition) => { + const textarea = textareaRef.current + if (textarea) { + const currentValue = valueRef.current + const range = slashRangeRef.current + let before: string + let after: string + let insertText: string + let newPos: number + + if (range) { + before = currentValue.slice(0, range.start) + after = currentValue.slice(range.end) + const needsSpaceBefore = + range.start > 0 && !/\s/.test(currentValue.charAt(range.start - 1)) + insertText = `${needsSpaceBefore ? ' ' : ''}${SKILL_CHIP_TRIGGER}${skill.name} ` + newPos = before.length + insertText.length + } else { + const insertAt = textarea.selectionStart ?? currentValue.length + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) + insertText = `${needsSpaceBefore ? ' ' : ''}${SKILL_CHIP_TRIGGER}${skill.name} ` + before = currentValue.slice(0, insertAt) + after = currentValue.slice(insertAt) + newPos = before.length + insertText.length + } + + const newValue = `${before}${insertText}${after}` + pendingCursorRef.current = newPos + valueRef.current = newValue + slashRangeRef.current = null + setSlashQuery(null) + setValue(newValue) + } + + handleContextAdd({ kind: 'skill', skillId: skill.id, label: skill.name }) + }, + [textareaRef, handleContextAdd] + ) + + const handleSkillsMenuClose = useCallback(() => { + slashRangeRef.current = null + setSlashQuery(null) + }, []) + const handlePlusMenuClose = useCallback(() => { atInsertPosRef.current = null mentionRangeRef.current = null @@ -621,8 +698,14 @@ export const UserInput = forwardRef(function Us ...(f.path ? { path: f.path } : {}), })) + // Skill chips store an EM SPACE sentinel in place of '/' so the centered + // icon fits its overlay slot. Restore the literal '/' in the outgoing text + // so the message reads as clean `/skill-name` (skills travel via contexts + // regardless). Only the submitted copy is converted; the live input is not. + const submittedValue = restoreSkillTriggerText(currentValue) + onSubmit( - currentValue, + submittedValue, fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined, currentContext.selectedContexts.length > 0 ? currentContext.selectedContexts : undefined ) @@ -641,6 +724,9 @@ export const UserInput = forwardRef(function Us plusMenuRef.current?.close() mentionRangeRef.current = null setMentionQuery(null) + skillsMenuRef.current?.close() + slashRangeRef.current = null + setSlashQuery(null) if (textareaRef.current) { textareaRef.current.style.height = 'auto' @@ -670,6 +756,27 @@ export const UserInput = forwardRef(function Us } } + if (slashRangeRef.current && !e.nativeEvent.isComposing) { + if (e.key === 'ArrowDown') { + e.preventDefault() + skillsMenuRef.current?.moveActive(1) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + skillsMenuRef.current?.moveActive(-1) + return + } + if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey) { + // Confirm the highlighted skill if there is one. If no items match, fall + // through so Enter still submits and Tab still does its default thing. + if (skillsMenuRef.current?.selectActive()) { + e.preventDefault() + return + } + } + } + if (e.key === 'ArrowUp' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { const isEmpty = valueRef.current.length === 0 && filesRef.current.attachedFiles.length === 0 if (isEmpty && onEditQueuedTailRef.current) { @@ -701,21 +808,20 @@ export const UserInput = forwardRef(function Us const selEnd = textarea?.selectionEnd ?? selStart const selectionLength = Math.abs(selEnd - selStart) - if (e.key === 'Backspace' || e.key === 'Delete') { - if (selectionLength > 0) { - mentionTokensWithContext.removeContextsInSelection(selStart, selEnd) - } else { - const ranges = mentionTokensWithContext.computeMentionRanges() - const target = - e.key === 'Backspace' - ? ranges.find((r) => selStart > r.start && selStart <= r.end) - : ranges.find((r) => selStart >= r.start && selStart < r.end) + // Single-chip delete: remove the token's text atomically. A selection + // delete falls through to the native edit; either way the context-sync + // effect prunes contexts whose last token is gone. + if ((e.key === 'Backspace' || e.key === 'Delete') && selectionLength === 0) { + const ranges = mentionTokensWithContext.mentionRanges + const target = + e.key === 'Backspace' + ? ranges.find((r) => selStart > r.start && selStart <= r.end) + : ranges.find((r) => selStart >= r.start && selStart < r.end) - if (target) { - e.preventDefault() - mentionTokensWithContext.deleteRange(target) - return - } + if (target) { + e.preventDefault() + mentionTokensWithContext.deleteRange(target) + return } } @@ -764,6 +870,9 @@ export const UserInput = forwardRef(function Us const getActiveMentionAtRef = useRef(mentionMenu.getActiveMentionQueryAtPosition) getActiveMentionAtRef.current = mentionMenu.getActiveMentionQueryAtPosition + const getActiveSlashAtRef = useRef(mentionMenu.getActiveSlashQueryAtPosition) + getActiveSlashAtRef.current = mentionMenu.getActiveSlashQueryAtPosition + const syncMentionState = useCallback( (textarea: HTMLTextAreaElement, text: string, caret: number) => { const active = getActiveMentionAtRef.current(caret, text) @@ -794,6 +903,57 @@ export const UserInput = forwardRef(function Us [] ) + const syncSlashState = useCallback( + (textarea: HTMLTextAreaElement, text: string, caret: number) => { + const active = getActiveSlashAtRef.current(caret, text) + // Any word-boundary character inside the query dismisses the menu. The + // boundary set intentionally excludes `/` so the slash itself doesn't + // self-dismiss the menu it just opened. + const isOpenable = active && !/[\s.,;:!?(){}[\]"'`\\<>]/.test(active.query) + if (!isOpenable) { + if (slashRangeRef.current !== null) { + slashRangeRef.current = null + setSlashQuery(null) + skillsMenuRef.current?.close() + } + return + } + + const wasActive = slashRangeRef.current !== null + slashRangeRef.current = { start: active.start, end: active.end } + setSlashQuery(active.query) + if (!wasActive) { + // Anchor at the caret so the menu floats above the user's cursor. + const anchor = getCaretAnchor(textarea, active.start) + skillsMenuRef.current?.open(anchor) + } + }, + [] + ) + + const handleSlashTriggerClick = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + textarea.focus() + + const currentValue = valueRef.current + const insertAt = textarea.selectionStart ?? currentValue.length + // A `/` only triggers the menu when it starts a token (at start or after + // whitespace). Insert a leading space when the preceding char isn't one. + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) + const insertText = `${needsSpaceBefore ? ' ' : ''}/` + const before = currentValue.slice(0, insertAt) + const after = currentValue.slice(insertAt) + const newValue = `${before}${insertText}${after}` + const newCaret = before.length + insertText.length + + valueRef.current = newValue + setValue(newValue) + textarea.value = newValue + textarea.setSelectionRange(newCaret, newCaret) + syncSlashState(textarea, newValue, newCaret) + }, [textareaRef, syncSlashState]) + const handleInputChange = useCallback( (e: React.ChangeEvent) => { const previousValue = valueRef.current @@ -807,21 +967,45 @@ export const UserInput = forwardRef(function Us previousValue, nextValue, }) + // Run skills on the post-integration value so a completed `/name` token + // chips. A confirmed skill rewrites its leading `/` to the wide sentinel + // (in the textarea and in the returned value) so the centered icon fits. + finalValue = skillAutoMention.processChange({ + textarea: e.target, + previousValue, + nextValue: finalValue, + }) } else if (nextValue.length > previousValue.length + 1) { - // Multi-char insertion (paste, drag-drop, IME commit) — bulk - // convert all matches and rewrite the textarea via `setRangeText` - // to keep the edit in a single native undo step. - finalValue = integrationAutoMention.applyToText(nextValue) + // Multi-char insertion (paste, drag-drop, IME commit) — bulk convert all + // matches and rewrite the textarea via `setRangeText` to keep the edit + // in a single native undo step. + finalValue = applyAutoMentions(nextValue) if (finalValue !== nextValue) { + const caretBefore = e.target.selectionStart ?? nextValue.length e.target.setRangeText(finalValue, 0, nextValue.length, 'preserve') + // Replacing the whole value leaves the selection spanning the replaced + // range (it would select all the text). Collapse the caret to its + // converted position: only insertions BEFORE the caret should shift + // it, so measure the converted length of just the leading slice (a + // converted name after the caret must not move it). The re-registered + // contexts dedupe in `mergeContexts`, so this is side-effect-safe. + const caretAfter = applyAutoMentions(nextValue.slice(0, caretBefore)).length + e.target.setSelectionRange(caretAfter, caretAfter) } } const caret = e.target.selectionStart ?? finalValue.length setValue(finalValue) syncMentionState(e.target, finalValue, caret) + syncSlashState(e.target, finalValue, caret) }, - [integrationAutoMention.applyToText, integrationAutoMention.processChange, syncMentionState] + [ + applyAutoMentions, + integrationAutoMention.processChange, + skillAutoMention.processChange, + syncMentionState, + syncSlashState, + ] ) const handleSelectAdjust = useCallback(() => { @@ -837,7 +1021,8 @@ export const UserInput = forwardRef(function Us return } syncMentionState(textarea, textarea.value, pos) - }, [textareaRef, mentionTokensWithContext, syncMentionState]) + syncSlashState(textarea, textarea.value, pos) + }, [textareaRef, mentionTokensWithContext, syncMentionState, syncSlashState]) const handleInput = useCallback( (e: React.FormEvent) => { @@ -852,6 +1037,68 @@ export const UserInput = forwardRef(function Us ) const handlePaste = useCallback((e: React.ClipboardEvent) => { + const textarea = e.currentTarget + + // Portable chip links (`[label](sim:kind/id)`) re-create their chip on + // paste-back. Rewrite each link span to its `@label ` token (the trailing + // space is REQUIRED so useContextManagement's sync effect doesn't purge the + // freshly-added context) and register the contexts directly. + const pastedText = e.clipboardData?.getData('text/plain') ?? '' + const links = parseChipLinks(pastedText) + if (links.length > 0) { + e.preventDefault() + + const pastedContexts: ChatContext[] = [] + let rewritten = '' + let cursor = 0 + for (let i = 0; i < links.length; i++) { + const link = links[i] + let between = pastedText.slice(cursor, link.start) + // Self-heal: a run of plain spaces sitting ENTIRELY between two chips is + // glue the codec re-emits, so collapse it to one space — gaps accumulated + // by earlier pastes clean themselves up. Newlines and prose-bordering + // whitespace contain non-space chars and are left verbatim. + if (i > 0 && /^ +$/.test(between)) between = ' ' + rewritten += between + const ctx = chipLinkToContext(link) + pastedContexts.push(ctx) + // Insert the kind-correct token (skill EM-SPACE sentinel, slash `/`, `@` + // else) so the chip re-renders with its proper trigger glyph and the + // context-sync effect (keyed on the same per-kind prefix) keeps it. Append + // a single separator ONLY when the next source char is non-whitespace + // (chip→chip / chip→word); existing whitespace and end-of-string already + // supply the boundary, so re-pasting never accumulates spaces. + const next = pastedText.charAt(link.end) + rewritten += /\S/.test(next) ? `${chipDisplayToken(ctx)} ` : chipDisplayToken(ctx) + cursor = link.end + } + rewritten += pastedText.slice(cursor) + + const selStart = textarea.selectionStart ?? valueRef.current.length + const selEnd = textarea.selectionEnd ?? selStart + const needsSpaceBefore = + selStart > 0 && + !/\s/.test(valueRef.current.charAt(selStart - 1)) && + /^[@/\u2003]/.test(rewritten) + const insert = needsSpaceBefore ? ` ${rewritten}` : rewritten + + textarea.setRangeText(insert, selStart, selEnd, 'end') + const newValue = textarea.value + const caret = selStart + insert.length + + // Use addContext directly — NOT handleContextAdd — so pasting does NOT + // auto-open the resource side panel (handleContextAdd fires onContextAdd). + for (const ctx of pastedContexts) contextRef.current.addContext(ctx) + + valueRef.current = newValue + setValue(newValue) + requestAnimationFrame(() => { + const ta = textareaRef.current + if (ta) ta.setSelectionRange(caret, caret) + }) + return + } + const items = e.clipboardData?.items if (!items) return @@ -879,6 +1126,58 @@ export const UserInput = forwardRef(function Us } }, []) + /** + * On copy/cut, write a portable representation of the selection to the + * clipboard. Portable resource chips (table/file/folder/etc.) become + * `[label](sim:kind/id)` markdown links so they read cleanly anywhere AND + * re-create their chip when pasted back into the input. Skill chips (whose + * EM SPACE sentinel maps back to `/`) and `@integration` tokens stay readable + * text and round-trip by name. Returns true when it took over the clipboard + * (the caller must then perform the cut deletion itself, since the default + * was prevented). + */ + const writeSanitizedClipboard = useCallback( + (e: React.ClipboardEvent): boolean => { + const textarea = e.currentTarget + const start = textarea.selectionStart ?? 0 + const end = textarea.selectionEnd ?? 0 + const selected = textarea.value.slice(start, end) + if (!selected) return false + const serialized = serializeSelectionForClipboard( + selected, + contextRef.current.selectedContexts + ) + if (serialized === selected) return false + e.preventDefault() + e.clipboardData.setData('text/plain', serialized) + return true + }, + [] + ) + + const handleCopy = useCallback( + (e: React.ClipboardEvent) => { + writeSanitizedClipboard(e) + }, + [writeSanitizedClipboard] + ) + + const handleCut = useCallback( + (e: React.ClipboardEvent) => { + // When the selection holds a portable chip (skill or resource) we take over + // the clipboard, so the selected text must be removed here (default prevented). + // Either way the context-sync effect prunes contexts whose token is now gone. + if (!writeSanitizedClipboard(e)) return + const textarea = e.currentTarget + const start = textarea.selectionStart ?? 0 + const end = textarea.selectionEnd ?? 0 + textarea.setRangeText('', start, end, 'end') + valueRef.current = textarea.value + setValue(textarea.value) + }, + [writeSanitizedClipboard] + ) + const overlayContent = useMemo(() => { const contexts = contextManagement.selectedContexts @@ -909,10 +1208,7 @@ export const UserInput = forwardRef(function Us elements.push({before}) } - const mentionLabel = - range.token.startsWith('@') || range.token.startsWith('/') - ? range.token.slice(1) - : range.token + const mentionLabel = stripMentionTrigger(range.token) const matchingCtx = contexts.find((c) => c.label === mentionLabel) const wfId = @@ -923,13 +1219,19 @@ export const UserInput = forwardRef(function Us ) : null elements.push( + {/* Spacer reserves the real trigger glyph's width so the overlay's + advance matches the transparent textarea char-for-char — + hardcoding a width here would drift the text. For '@' the glyph is + ~1em; skill chips store an EM SPACE sentinel (SKILL_CHIP_TRIGGER) + in place of the narrow '/' so the centered 12px icon fits its slot + exactly like '@' does. */} {range.token.charAt(0)} {mentionIconNode} @@ -984,7 +1286,8 @@ export const UserInput = forwardRef(function Us onKeyDown={handleKeyDown} onInput={handleInput} onPaste={handlePaste} - onCut={mentionTokensWithContext.handleCut} + onCopy={handleCopy} + onCut={handleCut} onSelect={handleSelectAdjust} onMouseUp={handleSelectAdjust} onScroll={handleScroll} @@ -1005,6 +1308,15 @@ export const UserInput = forwardRef(function Us pendingCursorRef={pendingCursorRef} mentionQuery={mentionQuery ?? undefined} /> + Attach file + + + + + Skills +
{isSttSupported && } diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 77f143bb44..60504d41cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -2,13 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { Button } from '@/components/emcn' import { PanelLeft } from '@/components/emcn/icons' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' -import { useSession } from '@/lib/auth/auth-client' import { LandingPromptStorage, type LandingWorkflowSeed, @@ -21,6 +20,7 @@ import { import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks' +import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { CreditsChip, @@ -36,14 +36,16 @@ const logger = createLogger('Home') interface HomeProps { chatId?: string + userName?: string + userId?: string + initialResourceId?: string | null } -export function Home({ chatId }: HomeProps = {}) { +export function Home({ chatId, userName, userId, initialResourceId = null }: HomeProps) { + useOAuthReturnRouter() const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() - const searchParams = useSearchParams() - const initialResourceId = searchParams.get('resource') - const { data: session } = useSession() + const firstName = userName?.split(' ')[0] ?? '' const posthog = usePostHog() const posthogRef = useRef(posthog) posthogRef.current = posthog @@ -306,8 +308,7 @@ export function Home({ chatId }: HomeProps = {}) {

- What should we get done - {session?.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}? + What should we get done{firstName ? `, ${firstName}` : ''}?

@@ -344,7 +345,7 @@ export function Home({ chatId }: HomeProps = {}) { onSendQueuedMessage={sendNow} onEditQueuedMessage={editQueuedMessage} onCancelQueueEdit={cancelQueueEdit} - userId={session?.user?.id} + userId={userId} chatId={resolvedChatId} onContextAdd={handleContextAdd} onWorkspaceResourceSelect={handleWorkspaceResourceSelect} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8bf2fd783a..eb323714ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4597,6 +4597,7 @@ export function useChat( ...('tableId' in c && c.tableId ? { tableId: c.tableId } : {}), ...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}), ...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}), + ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), })) const cachedUserMsg: PersistedMessage = { id: userMessageId, diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index f5acc86553..3f899743a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,15 +1,22 @@ -import { Suspense } from 'react' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' import { Home } from './home' export const metadata: Metadata = { title: 'Home', } -export default function HomePage() { +interface HomePageProps { + searchParams: Promise<{ resource?: string }> +} + +export default async function HomePage({ searchParams }: HomePageProps) { + const [session, { resource }] = await Promise.all([getSession(), searchParams]) return ( - - - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 05a7190258..7acfb13954 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -154,6 +154,7 @@ export interface ChatMessageContext { folderId?: string chatId?: string blockType?: string + skillId?: string } export interface ChatMessage { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index b84c5fd432..dff5ad264c 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -1,18 +1,18 @@ 'use client' -import { type ComponentType, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { Chip, ChipDropdown, ChipLink } from '@/components/emcn' import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' import { cn } from '@/lib/core/utils/cn' -import { blockTypeToIconMap, type Integration } from '@/lib/integrations' -import { OAUTH_PROVIDERS } from '@/lib/oauth' -import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-oauth-modal' import { - ConnectServiceAccountModal, - type ServiceAccountProviderId, -} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' + blockTypeToIconMap, + type Integration, + resolveOAuthServiceForIntegration, +} from '@/lib/integrations' +import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { CONNECT_MODE, @@ -30,57 +30,6 @@ const TEMPLATE_CLUSTER_MAX = 3 as const */ const TEMPLATE_TILE_Z = ['z-30', 'z-20', 'z-10'] as const -interface OAuthServiceMatch { - providerId: string - requiredScopes: string[] - serviceName: string - serviceIcon: ComponentType<{ className?: string }> - /** - * When set, the matched OAuth service also exposes a service-account flow - * (e.g. Google services with `google-service-account`, Atlassian services - * with `atlassian-service-account`). Drives the secondary "Add service - * account" entry in the connect dropdown. - */ - serviceAccountProviderId?: ServiceAccountProviderId -} - -/** - * Narrows the runtime `OAuthServiceConfig.serviceAccountProviderId` string to - * the {@link ServiceAccountProviderId} union. Anything outside the union is - * unsupported by `ConnectServiceAccountModal` and is silently ignored — the - * dropdown will fall back to the OAuth-only entry. - */ -function asServiceAccountProviderId( - value: string | undefined -): ServiceAccountProviderId | undefined { - if (value === 'google-service-account' || value === 'atlassian-service-account') return value - return undefined -} - -/** - * Looks up the OAuth service entry whose display name matches the integration - * (case-insensitive). Returns `null` for non-OAuth integrations or when no - * matching service is registered in `OAUTH_PROVIDERS`. - */ -function resolveOAuthServiceForIntegration(integration: Integration): OAuthServiceMatch | null { - if (integration.authType !== 'oauth') return null - const target = integration.name.toLowerCase() - for (const provider of Object.values(OAUTH_PROVIDERS)) { - for (const service of Object.values(provider.services)) { - if (service.name.toLowerCase() === target) { - return { - providerId: service.providerId, - requiredScopes: service.scopes ?? [], - serviceName: service.name, - serviceIcon: service.icon as ComponentType<{ className?: string }>, - serviceAccountProviderId: asServiceAccountProviderId(service.serviceAccountProviderId), - } - } - } - } - return null -} - interface IntegrationBlockDetailProps { integration: Integration workspaceId: string @@ -178,6 +127,8 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
{oauthService && (
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 3ec0cd4054..3dc8aadf66 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -2,12 +2,12 @@ import { redirect } from 'next/navigation' import { ToastProvider } from '@/components/emcn' import { getSession } from '@/lib/auth' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' +import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' 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' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync' -import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider' import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding' @@ -30,16 +30,7 @@ export default async function WorkspaceLayout({ children }: { children: React.Re -
-
- -
-
-
- {children} -
-
-
+ {children}
diff --git a/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx index 32e81af5e5..56afbf4038 100644 --- a/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' import { Home } from '@/app/workspace/[workspaceId]/home/home' export const metadata: Metadata = { @@ -10,9 +11,22 @@ interface TaskPageProps { workspaceId: string taskId: string }> + searchParams: Promise<{ resource?: string }> } -export default async function TaskPage({ params }: TaskPageProps) { - const { taskId } = await params - return +export default async function TaskPage({ params, searchParams }: TaskPageProps) { + const [{ taskId }, { resource }, session] = await Promise.all([ + params, + searchParams, + getSession(), + ]) + return ( + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle.tsx new file mode 100644 index 0000000000..197ce71350 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/billing-period-toggle.tsx @@ -0,0 +1,51 @@ +'use client' + +import { ChipSwitch, ChipTag } from '@/components/emcn' +import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants' + +/** + * Props for {@link BillingPeriodToggle}. + */ +export interface BillingPeriodToggleProps { + /** Whether the annual billing period is currently selected. */ + isAnnual: boolean + /** Invoked with the next selection when a segment is clicked. */ + onChange: (isAnnual: boolean) => void + /** Optional additional classes merged onto the container. */ + className?: string +} + +/** + * Discount label derived from the real billing constant so it stays in sync if + * the rate changes (e.g. `0.15` renders as `-15%`). + */ +const DISCOUNT_LABEL = `-${Math.round(ANNUAL_DISCOUNT_RATE * 100)}%` + +type Period = 'annual' | 'monthly' + +/** + * Annual / monthly billing-period segmented switch. Built on {@link ChipSwitch} + * with an inline brand-tinted discount badge on the annual segment. + */ +export function BillingPeriodToggle({ isAnnual, onChange, className }: BillingPeriodToggleProps) { + return ( + + aria-label='Billing period' + value={isAnnual ? 'annual' : 'monthly'} + onChange={(next) => onChange(next === 'annual')} + className={className} + options={[ + { + value: 'annual', + label: ( + <> + Annual + {DISCOUNT_LABEL} + + ), + }, + { value: 'monthly', label: 'Monthly' }, + ]} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/index.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/index.ts new file mode 100644 index 0000000000..f15995defd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-period-toggle/index.ts @@ -0,0 +1 @@ +export { BillingPeriodToggle, type BillingPeriodToggleProps } from './billing-period-toggle' diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-usage-notifications-toggle/billing-usage-notifications-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-usage-notifications-toggle/billing-usage-notifications-toggle.tsx new file mode 100644 index 0000000000..94e0fb7de1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-usage-notifications-toggle/billing-usage-notifications-toggle.tsx @@ -0,0 +1,37 @@ +'use client' + +import { Label, Switch } from '@/components/emcn' +import { + useBillingUsageNotifications, + useUpdateGeneralSetting, +} from '@/hooks/queries/general-settings' + +/** + * Toggle controlling whether the user receives an email when they reach 80% + * of their usage. Reads and writes the `billingUsageNotificationsEnabled` + * general setting directly. + */ +export function BillingUsageNotificationsToggle() { + const enabled = useBillingUsageNotifications() + const updateSetting = useUpdateGeneralSetting() + const isLoading = updateSetting.isPending + + return ( +
+
+ + Email me when I reach 80% usage +
+ { + if (v !== enabled) { + updateSetting.mutate({ key: 'billingUsageNotificationsEnabled', value: v }) + } + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-usage-notifications-toggle/index.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-usage-notifications-toggle/index.ts new file mode 100644 index 0000000000..a5f5a7993b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/billing-usage-notifications-toggle/index.ts @@ -0,0 +1 @@ +export { BillingUsageNotificationsToggle } from './billing-usage-notifications-toggle' diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/credit-balance/credit-balance.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/credit-balance/credit-balance.tsx new file mode 100644 index 0000000000..68fecc2a6d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/credit-balance/credit-balance.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { generateId } from '@sim/utils/id' +import { + Button, + Input, + Label, + Modal, + ModalBody, + ModalClose, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTrigger, +} from '@/components/emcn' +import { dollarsToCredits, formatCredits } from '@/lib/billing/credits/conversion' +import { usePurchaseCredits } from '@/hooks/queries/subscription' + +export interface CreditBalanceProps { + balance: number + canPurchase: boolean + entityType: 'user' | 'organization' + isLoading?: boolean + onPurchaseComplete?: () => void +} + +/** + * Displays credit balance with optional purchase modal. + */ +export function CreditBalance({ + balance, + canPurchase, + entityType, + isLoading, + onPurchaseComplete, +}: CreditBalanceProps) { + const [isOpen, setIsOpen] = useState(false) + const [amount, setAmount] = useState('') + const [validationError, setValidationError] = useState(null) + const [requestId, setRequestId] = useState(null) + const purchaseCredits = usePurchaseCredits() + const closeTimeoutRef = useRef | null>(null) + + const dollarAmount = Number.parseInt(amount, 10) || 0 + const creditPreview = dollarsToCredits(dollarAmount) + + const clearCloseTimeout = () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + } + + const resetModalState = () => { + setAmount('') + setValidationError(null) + purchaseCredits.reset() + } + + const openModal = () => { + clearCloseTimeout() + resetModalState() + setRequestId(generateId()) + setIsOpen(true) + } + + const closeModal = () => { + clearCloseTimeout() + setIsOpen(false) + setRequestId(null) + resetModalState() + } + + useEffect(() => { + return () => { + clearCloseTimeout() + } + }, []) + + const handleOpenChange = (open: boolean) => { + if (open) { + openModal() + return + } + + if (!purchaseCredits.isPending) { + closeModal() + } + } + + const handleAmountChange = (value: string) => { + const numericValue = value.replace(/[^0-9]/g, '') + setAmount(numericValue) + setValidationError(null) + } + + const handlePurchase = () => { + if (!requestId || purchaseCredits.isPending) return + + const numAmount = Number.parseInt(amount, 10) + + if (Number.isNaN(numAmount) || numAmount < 10) { + setValidationError('Minimum purchase is $10') + return + } + + if (numAmount > 1000) { + setValidationError('Maximum purchase is $1,000') + return + } + + purchaseCredits.mutate( + { amount: numAmount, requestId }, + { + onSuccess: () => { + onPurchaseComplete?.() + clearCloseTimeout() + closeTimeoutRef.current = setTimeout(() => { + closeModal() + }, 1500) + }, + } + ) + } + + const displayError = validationError || purchaseCredits.error?.message + + return ( +
+
+ + + {isLoading ? '...' : `${formatCredits(balance)} credits`} + +
+ + {canPurchase && ( + + + + + + Add Credits + + + Purchase additional credits by entering a dollar amount. + + {purchaseCredits.isSuccess ? ( +

+ Credits added successfully! +

+ ) : ( +
+
+ +
+ + $ + + handleAmountChange(e.target.value)} + placeholder='50' + className='pl-7' + disabled={purchaseCredits.isPending} + /> +
+ {dollarAmount > 0 && !displayError && ( + + You'll receive {creditPreview.toLocaleString()} credits + + )} + {displayError && ( + {displayError} + )} +
+ +
+

+ Credits are non-refundable and don't expire. They'll be applied automatically + to your {entityType === 'organization' ? 'team' : ''} usage. +

+
+
+ )} +
+ {!purchaseCredits.isSuccess && ( + + + + + + + )} +
+
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/credit-balance/index.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/credit-balance/index.ts new file mode 100644 index 0000000000..dc353fa927 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/credit-balance/index.ts @@ -0,0 +1 @@ +export { CreditBalance, type CreditBalanceProps } from './credit-balance' diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/index.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/index.ts new file mode 100644 index 0000000000..09bdbb56dd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/index.ts @@ -0,0 +1,5 @@ +export { BillingPeriodToggle, type BillingPeriodToggleProps } from './billing-period-toggle' +export { BillingUsageNotificationsToggle } from './billing-usage-notifications-toggle' +export { CreditBalance, type CreditBalanceProps } from './credit-balance' +export { ManagePlanModal, type ManagePlanModalProps } from './manage-plan-modal' +export { UpgradePlanCard, type UpgradePlanCardProps } from './plan-card' diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/manage-plan-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/manage-plan-modal/index.ts new file mode 100644 index 0000000000..e0bf4801c3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/manage-plan-modal/index.ts @@ -0,0 +1,6 @@ +export { + type BilledAccountAdmin, + type ManagePlanBillingDetails, + ManagePlanModal, + type ManagePlanModalProps, +} from './manage-plan-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/manage-plan-modal/manage-plan-modal.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/manage-plan-modal/manage-plan-modal.tsx new file mode 100644 index 0000000000..2e5516c931 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/manage-plan-modal/manage-plan-modal.tsx @@ -0,0 +1,367 @@ +'use client' + +import type { ReactNode } from 'react' +import { useRef, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { Info } from 'lucide-react' +import { + Badge, + Button, + Combobox, + Label, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + Tooltip, +} from '@/components/emcn' +import { ANNUAL_DISCOUNT_RATE, CREDIT_TIERS } from '@/lib/billing/constants' +import { cn } from '@/lib/core/utils/cn' + +const PRO_TIER = CREDIT_TIERS[0] +const MAX_TIER = CREDIT_TIERS[1] + +/** + * Single admin user selectable in the "Billed Account" combobox. + */ +export interface BilledAccountAdmin { + userId: string + email: string +} + +/** + * Optional "Billing details" section embedded inside the modal. When omitted, + * the modal renders only the plan-action list. Surface this only for paid + * customers where billing controls are meaningful. + */ +export interface ManagePlanBillingDetails { + /** Rendered usage header (plan title, usage progress, on-demand toggle). */ + usageHeader?: ReactNode + /** Credit balance card. */ + creditBalance?: ReactNode + /** Billing usage notifications toggle. */ + notificationsToggle?: ReactNode + /** Period end date (renders next-billing/access-until row). */ + periodEnd?: string | null + /** Whether the subscription is cancelled at period end. */ + isCancelledAtPeriodEnd?: boolean + /** Whether to show the Stripe payment / invoices actions. */ + showStripeActions?: boolean + /** Whether the Stripe portal call is in flight. */ + isBillingPortalPending?: boolean + /** Opens the Stripe billing portal. */ + onOpenBillingPortal?: () => void + /** Whether to show the Billed Account combobox. */ + showBilledAccount?: boolean + /** Workspace admins eligible to receive billing. */ + workspaceAdmins?: BilledAccountAdmin[] + /** Currently selected billed admin. */ + billedAccountUserId?: string | null + /** Whether the combobox is interactable. */ + canManageWorkspaceKeys?: boolean + /** Whether the workspace-settings mutation is in flight. */ + isUpdatingWorkspace?: boolean + /** Selection handler for the billed account. */ + onChangeBilledAccount?: (userId: string) => Promise +} + +/** + * Props for {@link ManagePlanModal}. + */ +export interface ManagePlanModalProps { + open: boolean + onOpenChange: (open: boolean) => void + currentPlanCredits: number + currentPlanDollars: number + currentInterval: 'month' | 'year' + isTeamPlan: boolean + isCancelledAtPeriodEnd: boolean + isLegacyPlan: boolean + onSwitchInterval: (interval: 'month' | 'year') => Promise + onUpgradeToOtherTier: () => void + onUpgradeToCurrentTier: () => void + onCancel: () => void + onRestore: () => void + /** Optional billing-details section embedded inside the modal body. */ + billingDetails?: ManagePlanBillingDetails +} + +/** + * Modal for managing the current paid plan: switch billing interval, upgrade + * or switch tiers, view usage and credit balance, manage payment methods, and + * cancel or restore the subscription. + */ +export function ManagePlanModal({ + open, + onOpenChange, + currentPlanCredits, + currentPlanDollars, + currentInterval, + isTeamPlan, + isCancelledAtPeriodEnd, + isLegacyPlan, + onSwitchInterval, + onUpgradeToOtherTier, + onUpgradeToCurrentTier, + onCancel, + onRestore, + billingDetails, +}: ManagePlanModalProps) { + const [isSwitching, setIsSwitching] = useState(false) + const [error, setError] = useState(null) + + const prevOpenRef = useRef(open) + if (prevOpenRef.current !== open) { + prevOpenRef.current = open + if (open) setError(null) + } + + const isOnMax = currentPlanCredits === MAX_TIER.credits || (isLegacyPlan && isTeamPlan) + const currentTier = isOnMax ? MAX_TIER : PRO_TIER + const otherTier = isOnMax ? PRO_TIER : MAX_TIER + const isUpgrade = otherTier.dollars > currentTier.dollars + const targetInterval = currentInterval === 'month' ? 'year' : 'month' + + const perUnit = isTeamPlan ? '/seat' : '' + const actualAnnualTotal = Math.round(currentPlanDollars * 12 * (1 - ANNUAL_DISCOUNT_RATE)) + const actualDiscountedMonthly = Math.round(actualAnnualTotal / 12) + + const handleSwitchInterval = async () => { + setIsSwitching(true) + setError(null) + try { + await onSwitchInterval(targetInterval) + } catch (e) { + setError(getErrorMessage(e, 'Failed to switch interval')) + } finally { + setIsSwitching(false) + } + } + + const actions = [ + ...(!isLegacyPlan + ? [ + { + title: + currentInterval === 'month' + ? 'Switch to annual billing' + : 'Switch to monthly billing', + description: + currentInterval === 'month' + ? `$${actualDiscountedMonthly}/mo${perUnit} ($${actualAnnualTotal}/yr${perUnit}) — save 15%` + : `$${currentTier.dollars}/mo${perUnit} — billed monthly`, + buttonText: isSwitching ? 'Switching...' : 'Switch', + onClick: handleSwitchInterval, + disabled: isSwitching, + }, + ] + : [ + { + title: `Upgrade to current ${currentTier.name} plan`, + description: `${currentTier.credits.toLocaleString()} credits/mo · $${currentTier.dollars}/mo${perUnit} — unlocks annual billing`, + buttonText: isSwitching ? 'Upgrading...' : 'Upgrade', + onClick: onUpgradeToCurrentTier, + disabled: isSwitching, + }, + ]), + { + title: isUpgrade ? `Upgrade to ${otherTier.name}` : `Switch to ${otherTier.name}`, + description: `${otherTier.credits.toLocaleString()} credits/mo · $${otherTier.dollars}/mo${perUnit}`, + buttonText: isUpgrade ? 'Upgrade' : 'Switch', + onClick: onUpgradeToOtherTier, + }, + ] + + const hasBillingDetails = + !!billingDetails && + (!!billingDetails.usageHeader || + !!billingDetails.creditBalance || + !!billingDetails.notificationsToggle || + !!billingDetails.periodEnd || + !!billingDetails.showStripeActions || + !!billingDetails.showBilledAccount) + + const modalSize = hasBillingDetails ? 'lg' : 'sm' + + return ( + + + + Manage {currentTier.name} Plan{isTeamPlan ? ' (Team)' : ''} + + + + You're on the{' '} + {currentTier.name} plan + {isTeamPlan ? ' for your team' : ''}, billed{' '} + {currentInterval === 'month' + ? `$${currentPlanDollars}/mo${perUnit}` + : `$${actualAnnualTotal}/yr${perUnit} ($${actualDiscountedMonthly}/mo${perUnit})`} + . + + + {isLegacyPlan && ( + + You're on an older version of this plan + + )} + +
+ {actions.map((action, i) => ( +
0 && 'border-[var(--border-1)] border-t' + )} + > +
+ + {action.title} + + + {action.description} + +
+ +
+ ))} +
+ + {error && ( + {error} + )} + + {hasBillingDetails && billingDetails && ( +
+ + Billing details + + + {billingDetails.usageHeader} + + {billingDetails.creditBalance} + + {billingDetails.periodEnd && ( +
+ + + {new Date(billingDetails.periodEnd).toLocaleDateString()} + +
+ )} + + {billingDetails.notificationsToggle} + + {billingDetails.showStripeActions && ( + <> +
+ + +
+ +
+ + +
+ + )} + + {billingDetails.showBilledAccount && ( +
+
+ + + + + + + Usage from this workspace will be billed to this account + + +
+ {(billingDetails.workspaceAdmins ?? []).length === 0 ? ( +
+ No admins available +
+ ) : ( +
+ { + if (value && value !== billingDetails.billedAccountUserId) { + try { + await billingDetails.onChangeBilledAccount?.(value) + } catch { + /* logged in hook */ + } + } + }} + disabled={ + !billingDetails.canManageWorkspaceKeys || + !!billingDetails.isUpdatingWorkspace + } + placeholder='Select admin' + options={(billingDetails.workspaceAdmins ?? []).map((admin) => ({ + label: admin.email, + value: admin.userId, + }))} + /> +
+ )} +
+ )} +
+ )} +
+ + {isCancelledAtPeriodEnd ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/index.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/index.ts new file mode 100644 index 0000000000..b943c260d2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/index.ts @@ -0,0 +1 @@ +export { UpgradePlanCard, type UpgradePlanCardProps } from './plan-card' diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx new file mode 100644 index 0000000000..287e706242 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx @@ -0,0 +1,135 @@ +'use client' + +import { ChipTag, chipVariants } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' + +/** + * Props for {@link UpgradePlanCard}. + */ +export interface UpgradePlanCardProps { + /** Plan name, e.g. `"Pro"`. */ + name: string + /** Headline price, e.g. `"$29"` or `"Custom"`. */ + price: string + /** Small line under the price, e.g. `"per user/month, billed annually"`. */ + priceSubtext?: string + /** Optional discount pill rendered next to the price, e.g. `"20% off"`. */ + discountLabel?: string + /** Optional struck-through original price, e.g. `"$34"`. */ + strikethroughPrice?: string + /** Short description below the plan name, e.g. `"For growing teams"`. */ + segmentLabel: string + /** Feature list — each row renders with a check icon. */ + features: string[] + /** CTA label. */ + buttonText: string + /** CTA click handler. */ + onButtonClick: () => void + /** Whether the CTA is disabled. */ + buttonDisabled?: boolean + /** When set, the CTA renders with the primary chip variant. */ + highlighted?: boolean + /** Optional pill rendered in the top-right corner, e.g. `"Your plan"`. */ + bannerText?: string + /** Extra outer classes. */ + className?: string +} + +/** + * Inline check icon — inherits color via `currentColor` from its `
  • ` parent. + */ +function CheckIcon() { + return ( + + ) +} + +/** + * Vertical pricing card styled with workspace surface, border, and text tokens. + * CTA reuses {@link chipVariants} so it picks up the platform's standard 30px + * pill chrome (primary filled when highlighted, neutral filled otherwise). + */ +export function UpgradePlanCard({ + name, + price, + priceSubtext, + discountLabel, + strikethroughPrice, + segmentLabel, + features, + buttonText, + onButtonClick, + buttonDisabled, + highlighted, + bannerText, + className, +}: UpgradePlanCardProps) { + const showPill = Boolean(bannerText) + + return ( +
    +
    +
    +

    {name}

    + {showPill && {bannerText}} +
    + +
    +
    + + {price} + + {discountLabel && {discountLabel}} + {strikethroughPrice && ( + + {strikethroughPrice} + + )} +
    +

    {priceSubtext ?? '\u00A0'}

    +
    + + +
    + +
    +

    {segmentLabel}

    +
      + {features.map((feature) => ( +
    • + + {feature} +
    • + ))} +
    +
    +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts new file mode 100644 index 0000000000..d04e8f8ee8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts @@ -0,0 +1,5 @@ +export { + type UpgradeState, + type UpgradeStateWorkspaceAdmin, + useUpgradeState, +} from './use-upgrade-state' diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts new file mode 100644 index 0000000000..d839685c74 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts @@ -0,0 +1,654 @@ +'use client' +import type { RefObject } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { useParams } from 'next/navigation' +import { requestJson } from '@/lib/api/client/request' +import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription' +import { useSession, useSubscription } from '@/lib/auth/auth-client' +import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade' +import { CREDIT_TIERS, DAILY_REFRESH_RATE, ON_DEMAND_UNLIMITED } from '@/lib/billing/constants' +import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion' +import { + getPlanTierCredits, + getPlanTierDollars, + isEnterprise, + isFree, + isPaid, + isPro, + isTeam, +} from '@/lib/billing/plan-helpers' +import { + getEffectiveSeats, + hasPaidSubscriptionStatus, + hasUsableSubscriptionAccess, +} from '@/lib/billing/subscriptions/utils' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import type { UsageLimitRef } from '@/app/workspace/[workspaceId]/settings/components/usage-limit' +import type { SubscriptionPermissions } from '@/app/workspace/[workspaceId]/upgrade/subscription-permissions' +import { + getSubscriptionPermissions, + getVisiblePlans, +} from '@/app/workspace/[workspaceId]/upgrade/subscription-permissions' +import { + useOrganizationBilling, + useOrganizations, + useUpdateOrganizationUsageLimit, +} from '@/hooks/queries/organization' +import { + useOpenBillingPortal, + useSubscriptionData, + useUpdateUsageLimit, + useUsageLimitData, +} from '@/hooks/queries/subscription' +import { useUpdateWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace' + +const PRO_TIER = CREDIT_TIERS[0] +const MAX_TIER = CREDIT_TIERS[1] + +const logger = createLogger('UpgradeState') + +type TargetPlan = 'pro' | 'team' + +export interface UpgradeStateWorkspaceAdmin { + userId: string + email: string + permissionType: string +} + +export interface UpgradeState { + isLoading: boolean + isAnnual: boolean + setIsAnnual: (v: boolean) => void + subscription: { + isFree: boolean + isPro: boolean + isTeam: boolean + isEnterprise: boolean + isPaid: boolean + isOrgScoped: boolean + plan: string + status: string + seats: number + } + isLegacyPlan: boolean + isCancelledAtPeriodEnd: boolean + currentInterval: 'month' | 'year' + permissions: SubscriptionPermissions + visiblePlans: Array<'pro' | 'team' | 'enterprise'> + showUpgradePlans: boolean + hasEnterprise: boolean + proTier: { credits: number; dollars: number; name: string } + maxTier: { credits: number; dollars: number; name: string } + proDailyRefresh: number + maxDailyRefresh: number + showProCard: boolean + isOnPro: boolean + isOnMax: boolean + isOnProTier: boolean + isOnMaxTier: boolean + wantsIntervalSwitch: boolean + usage: { current: number; limit: number; percentUsed: number } + usageLimitData: { currentLimit: number; minimumLimit: number } + organizationBillingData: ReturnType['data'] + billingOrganizationId: string | null + isTeamAdmin: boolean + shouldUseOrganizationBillingContext: boolean + onDemandState: 'hidden' | 'enable' | 'disable' + canDisableOnDemand: boolean + hasUsablePaidAccess: boolean + isBlocked: boolean + showBadge: boolean + badgeConfig: { text: string; variant: 'blue-secondary' | 'red' } + creditBalance: number + periodEnd: string | null + workspaceId: string + isGrandfatheredSharedWorkspace: boolean + workspaceAdmins: UpgradeStateWorkspaceAdmin[] + billedAccountUserId: string | null + canManageWorkspaceKeys: boolean + updateWorkspaceSettings: (updates: { billedAccountUserId?: string }) => Promise + isUpdatingWorkspace: boolean + isBillingPortalPending: boolean + managePlanModalOpen: boolean + setManagePlanModalOpen: (open: boolean) => void + usageLimitRef: RefObject + doUpgrade: (targetPlan: 'pro' | 'team', creditTier: number, seats?: number) => Promise + handleSwitchInterval: (interval: 'month' | 'year') => Promise + handleToggleOnDemand: () => Promise + handleBadgeClick: () => Promise + onCancel: () => Promise + onRestore: () => Promise + openBillingPortalWindow: () => void + upgradeOrSwitchToMax: () => Promise + onUpgradeToOtherTier: () => Promise + onUpgradeToCurrentTier: () => Promise + refetchSubscription: () => Promise +} + +/** + * Subscription / billing state hook for the Upgrade page. Reproduces the + * derived state and handlers from the Subscription settings component. + */ +export function useUpgradeState(): UpgradeState { + const { data: session } = useSession() + const { handleUpgrade } = useSubscriptionUpgrade() + const betterAuthSubscription = useSubscription() + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' + const userPermissions = useUserPermissionsContext() + const canManageWorkspaceKeys = userPermissions.canAdmin + + const { + data: subscriptionData, + isLoading: isSubscriptionLoading, + refetch: refetchSubscription, + } = useSubscriptionData({ includeOrg: true }) + const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData() + const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId) + const updateWorkspaceMutation = useUpdateWorkspaceSettings() + + const { data: orgsData } = useOrganizations() + const activeOrganization = orgsData?.activeOrganization + const activeOrgId = activeOrganization?.id + const workspaceOrganizationId = workspaceData?.settings?.workspace?.organizationId ?? null + const billingOrganizationId = + workspaceOrganizationId ?? subscriptionData?.data?.organization?.id ?? activeOrgId ?? null + + const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling( + billingOrganizationId || '' + ) + + const openBillingPortal = useOpenBillingPortal() + const updateUserLimit = useUpdateUsageLimit() + const updateOrgLimit = useUpdateOrganizationUsageLimit() + const [isAnnual, setIsAnnual] = useState(false) + const [managePlanModalOpen, setManagePlanModalOpen] = useState(false) + const usageLimitRef = useRef(null) + const hasInitializedInterval = useRef(false) + + const hasOrgScopedSubscription = Boolean(subscriptionData?.data?.isOrgScoped) + const isLoading = + isSubscriptionLoading || + isUsageLimitLoading || + isWorkspaceLoading || + (hasOrgScopedSubscription && isOrgBillingLoading) + + const isCancelledAtPeriodEnd = subscriptionData?.data?.cancelAtPeriodEnd === true + + const subscription = { + isFree: isFree(subscriptionData?.data?.plan), + isPro: isPro(subscriptionData?.data?.plan), + isTeam: isTeam(subscriptionData?.data?.plan), + isEnterprise: isEnterprise(subscriptionData?.data?.plan), + isPaid: + isPaid(subscriptionData?.data?.plan) && + hasPaidSubscriptionStatus(subscriptionData?.data?.status), + /** + * True when the subscription is attached to an org (regardless of plan + * name). Drives routing of usage-limit edits and whether we show pooled + * or personal usage. + */ + isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped), + plan: subscriptionData?.data?.plan || 'free', + status: subscriptionData?.data?.status || 'inactive', + seats: getEffectiveSeats(subscriptionData?.data), + } + + const isLegacyPlan = subscription.plan === 'pro' || subscription.plan === 'team' + + const usage = { + current: subscriptionData?.data?.usage?.current || 0, + limit: subscriptionData?.data?.usage?.limit || 0, + percentUsed: subscriptionData?.data?.usage?.percentUsed || 0, + } + + const usageLimitData = { + currentLimit: usageLimitResponse?.data?.currentLimit || 0, + minimumLimit: usageLimitResponse?.data?.minimumLimit || getPlanTierDollars(subscription.plan), + } + + const isBlocked = Boolean(subscriptionData?.data?.billingBlocked) + const blockedReason = subscriptionData?.data?.billingBlockedReason as + | 'payment_failed' + | 'dispute' + | null + const isDispute = isBlocked && blockedReason === 'dispute' + + const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null + const workspaceMode = workspaceData?.settings?.workspace?.workspaceMode ?? null + const isGrandfatheredSharedWorkspace = workspaceMode === 'grandfathered_shared' + const workspaceAdmins: UpgradeStateWorkspaceAdmin[] = + workspaceData?.permissions?.users?.filter( + (user: UpgradeStateWorkspaceAdmin) => user.permissionType === 'admin' + ) || [] + + const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => { + if (!workspaceId) return + try { + await updateWorkspaceMutation.mutateAsync({ workspaceId, ...updates }) + } catch (error) { + logger.error('Error updating workspace settings:', { error }) + throw error + } + } + + useEffect(() => { + if (!hasInitializedInterval.current && subscriptionData?.data?.billingInterval) { + hasInitializedInterval.current = true + setIsAnnual(subscriptionData.data.billingInterval === 'year') + } + }, [subscriptionData?.data?.billingInterval]) + + const userRole = subscriptionData?.data?.organization?.role ?? 'member' + const isTeamAdmin = ['owner', 'admin'].includes(userRole) + const shouldUseOrganizationBillingContext = subscription.isOrgScoped && isTeamAdmin + + const planIncludedAmount = + subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data + ? organizationBillingData.data.minimumBillingAmount + : getPlanTierCredits(subscription.plan) / CREDIT_MULTIPLIER + + const effectiveUsageLimit = + subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data + ? organizationBillingData.data.totalUsageLimit + : usageLimitData.currentLimit || usage.limit + + const isOnDemandActive = + subscription.isPaid && planIncludedAmount > 0 && effectiveUsageLimit > planIncludedAmount + + const effectiveCurrentUsage = + subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null + ? organizationBillingData.data.totalCurrentUsage + : usage.current + + const canDisableOnDemand = isOnDemandActive && effectiveCurrentUsage <= planIncludedAmount + + const handleToggleOnDemand = useCallback(async () => { + try { + if (shouldUseOrganizationBillingContext && !billingOrganizationId) { + throw new Error( + 'Organization billing context is unavailable. Please refresh and try again.' + ) + } + + if (isOnDemandActive) { + if (!canDisableOnDemand) return + if (shouldUseOrganizationBillingContext) { + await updateOrgLimit.mutateAsync({ + organizationId: billingOrganizationId!, + limit: planIncludedAmount, + }) + } else { + await updateUserLimit.mutateAsync({ limit: planIncludedAmount }) + } + } else { + if (shouldUseOrganizationBillingContext) { + await updateOrgLimit.mutateAsync({ + organizationId: billingOrganizationId!, + limit: ON_DEMAND_UNLIMITED, + }) + } else { + await updateUserLimit.mutateAsync({ limit: ON_DEMAND_UNLIMITED }) + } + } + } catch (error) { + logger.error('Failed to toggle on-demand billing', { error }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutateAsync is stable in TanStack Query v5 + }, [ + isOnDemandActive, + canDisableOnDemand, + shouldUseOrganizationBillingContext, + billingOrganizationId, + planIncludedAmount, + ]) + + const permissions = getSubscriptionPermissions( + { + isFree: subscription.isFree, + isPro: subscription.isPro, + isTeam: subscription.isTeam, + isEnterprise: subscription.isEnterprise, + isPaid: subscription.isPaid, + isOrgScoped: subscription.isOrgScoped, + plan: subscription.plan || 'free', + status: subscription.status || 'inactive', + }, + { isTeamAdmin, userRole: userRole || 'member' } + ) + + const visiblePlans = getVisiblePlans( + { + isFree: subscription.isFree, + isPro: subscription.isPro, + isTeam: subscription.isTeam, + isEnterprise: subscription.isEnterprise, + isPaid: subscription.isPaid, + isOrgScoped: subscription.isOrgScoped, + plan: subscription.plan || 'free', + status: subscription.status || 'inactive', + }, + { isTeamAdmin, userRole: userRole || 'member' } + ) + + const showBadge = + !permissions.isEnterpriseMember && + (permissions.showTeamMemberView || + subscription.isEnterprise || + isBlocked || + subscription.isFree) + + const getBadgeConfig = (): { text: string; variant: 'blue-secondary' | 'red' } => { + if (permissions.isEnterpriseMember) return { text: '', variant: 'blue-secondary' } + if (permissions.showTeamMemberView || subscription.isEnterprise) + return { text: `${subscription.seats} seats`, variant: 'blue-secondary' } + if (isDispute) return { text: 'Get Help', variant: 'red' } + if (isBlocked) return { text: 'Fix Now', variant: 'red' } + if (subscription.isFree) return { text: 'Upgrade', variant: 'blue-secondary' } + return { text: '', variant: 'blue-secondary' } + } + const badgeConfig = getBadgeConfig() + + const hasUsablePaidAccess = subscription.isPaid + ? hasUsableSubscriptionAccess(subscription.status, isBlocked) + : false + + const onDemandState: 'hidden' | 'enable' | 'disable' = (() => { + if (!hasUsablePaidAccess || !permissions.canEditUsageLimit) return 'hidden' + return isOnDemandActive ? 'disable' : 'enable' + })() + + const doUpgrade = useCallback( + async (targetPlan: TargetPlan, creditTier: number, seats?: number) => { + try { + await handleUpgrade(targetPlan, { + creditTier, + annual: isAnnual, + ...(seats ? { seats } : {}), + }) + } catch (error) { + alert(getErrorMessage(error, 'Unknown error occurred')) + } + }, + [handleUpgrade, isAnnual] + ) + + const handleBadgeClick = useCallback(async () => { + if (isDispute) { + window.dispatchEvent(new CustomEvent('open-help-modal')) + return + } + if (isBlocked) { + const context = subscription.isOrgScoped ? 'organization' : 'user' + if (context === 'organization' && !billingOrganizationId) { + alert('Organization billing context is unavailable. Please refresh and try again.') + return + } + openBillingPortal.mutate( + { + context, + organizationId: billingOrganizationId ?? undefined, + returnUrl: `${getBaseUrl()}/workspace?billing=updated`, + }, + { + onSuccess: (data) => { + window.location.href = data.url + }, + onError: (error) => { + logger.error('Failed to open billing portal', { error }) + alert(error.message) + }, + } + ) + return + } + if (subscription.isFree) { + doUpgrade('pro', PRO_TIER.credits) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- openBillingPortal.mutate is stable in TanStack Query v5 + }, [ + isDispute, + isBlocked, + subscription.isFree, + subscription.isOrgScoped, + billingOrganizationId, + doUpgrade, + ]) + + const currentInterval: 'month' | 'year' = + subscriptionData?.data?.billingInterval === 'year' ? 'year' : 'month' + + const handleSwitchInterval = useCallback( + async (interval: 'month' | 'year') => { + if (isLegacyPlan) { + throw new Error( + 'Interval switching is not available on legacy plans. Please upgrade first.' + ) + } + await requestJson(billingSwitchPlanContract, { + body: { targetPlanName: subscription.plan, interval }, + }) + await refetchSubscription() + }, + [refetchSubscription, subscription.plan, isLegacyPlan] + ) + + const proDailyRefresh = Math.round(PRO_TIER.dollars * DAILY_REFRESH_RATE * CREDIT_MULTIPLIER) + const maxDailyRefresh = Math.round(MAX_TIER.dollars * DAILY_REFRESH_RATE * CREDIT_MULTIPLIER) + + const showUpgradePlans = permissions.showUpgradePlans + const hasEnterprise = visiblePlans.includes('enterprise') + + const currentCredits = getPlanTierCredits(subscription.plan) + const hasPaidPlan = isPro(subscription.plan) || isTeam(subscription.plan) + const isLegacyTeam = subscription.plan === 'team' + const isOnKnownTier = currentCredits === PRO_TIER.credits || currentCredits === MAX_TIER.credits + const isOnProTier = + hasPaidPlan && + !isLegacyTeam && + (currentCredits === PRO_TIER.credits || (!isOnKnownTier && !subscription.isTeam)) + const isOnMaxTier = + hasPaidPlan && + (currentCredits === MAX_TIER.credits || isLegacyTeam || (!isOnKnownTier && subscription.isTeam)) + const wantsIntervalSwitch = + hasPaidPlan && !isLegacyPlan && isAnnual !== (currentInterval === 'year') + const isOnPro = isOnProTier && !wantsIntervalSwitch + const isOnMax = isOnMaxTier && !wantsIntervalSwitch + const showProCard = !isOnMaxTier + + const onCancel = useCallback(async () => { + setManagePlanModalOpen(false) + if (!betterAuthSubscription.cancel) return + try { + const isOrgSub = subscription.isOrgScoped + const referenceId = isOrgSub + ? (() => { + if (!billingOrganizationId) { + throw new Error( + 'Organization billing context is unavailable. Please refresh and try again.' + ) + } + return billingOrganizationId + })() + : session?.user?.id || '' + const returnUrl = getBaseUrl() + window.location.pathname + await betterAuthSubscription.cancel({ returnUrl, referenceId }) + } catch (e) { + logger.error('Failed to cancel subscription', { error: e }) + alert(getErrorMessage(e, 'Failed to cancel subscription')) + } + }, [betterAuthSubscription, subscription.isOrgScoped, billingOrganizationId, session?.user?.id]) + + const onRestore = useCallback(async () => { + if (!betterAuthSubscription.restore) return + try { + const isOrgSub = subscription.isOrgScoped + const referenceId = isOrgSub + ? (() => { + if (!billingOrganizationId) { + throw new Error( + 'Organization billing context is unavailable. Please refresh and try again.' + ) + } + return billingOrganizationId + })() + : session?.user?.id || '' + await betterAuthSubscription.restore({ referenceId }) + await refetchSubscription() + setManagePlanModalOpen(false) + } catch (e) { + logger.error('Failed to restore subscription', { error: e }) + alert(getErrorMessage(e, 'Failed to restore subscription')) + } + }, [ + betterAuthSubscription, + subscription.isOrgScoped, + billingOrganizationId, + session?.user?.id, + refetchSubscription, + ]) + + const openBillingPortalWindow = useCallback(() => { + const portalWindow = window.open('', '_blank') + const context = subscription.isOrgScoped ? 'organization' : 'user' + if (context === 'organization' && !billingOrganizationId) { + portalWindow?.close() + alert('Organization billing context is unavailable. Please refresh and try again.') + return + } + openBillingPortal.mutate( + { + context, + organizationId: billingOrganizationId ?? undefined, + returnUrl: window.location.href, + }, + { + onSuccess: (data) => { + if (portalWindow) { + portalWindow.location.href = data.url + } else { + window.location.href = data.url + } + }, + onError: (error) => { + portalWindow?.close() + logger.error('Failed to open billing portal', { error }) + alert(error.message) + }, + } + ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- openBillingPortal.mutate is stable in TanStack Query v5 + }, [subscription.isOrgScoped, billingOrganizationId]) + + const upgradeOrSwitchToMax = useCallback(async () => { + const planType = subscription.isTeam ? 'team' : 'pro' + try { + await requestJson(billingSwitchPlanContract, { + body: { + targetPlanName: `${planType}_${MAX_TIER.credits}`, + interval: isAnnual ? 'year' : 'month', + }, + }) + await refetchSubscription() + } catch (e) { + alert(getErrorMessage(e, 'Failed to upgrade')) + } + }, [subscription.isTeam, isAnnual, refetchSubscription]) + + const onUpgradeToOtherTier = useCallback(async () => { + const onMax = + getPlanTierCredits(subscription.plan) === MAX_TIER.credits || subscription.plan === 'team' + const targetTier = onMax ? PRO_TIER : MAX_TIER + const planType = subscription.isTeam ? 'team' : 'pro' + const targetPlanName = `${planType}_${targetTier.credits}` + try { + await requestJson(billingSwitchPlanContract, { + body: { targetPlanName }, + }) + await refetchSubscription() + setManagePlanModalOpen(false) + } catch (e) { + alert(getErrorMessage(e, 'Failed to switch plan')) + } + }, [subscription.plan, subscription.isTeam, refetchSubscription]) + + const onUpgradeToCurrentTier = useCallback(async () => { + const onMax = + getPlanTierCredits(subscription.plan) === MAX_TIER.credits || subscription.plan === 'team' + const currentTier = onMax ? MAX_TIER : PRO_TIER + const planType = subscription.isTeam ? 'team' : 'pro' + const targetPlanName = `${planType}_${currentTier.credits}` + try { + await requestJson(billingSwitchPlanContract, { + body: { targetPlanName }, + }) + await refetchSubscription() + setManagePlanModalOpen(false) + } catch (e) { + alert(getErrorMessage(e, 'Failed to migrate plan')) + } + }, [subscription.plan, subscription.isTeam, refetchSubscription]) + + return { + isLoading, + isAnnual, + setIsAnnual, + subscription, + isLegacyPlan, + isCancelledAtPeriodEnd, + currentInterval, + permissions, + visiblePlans, + showUpgradePlans, + hasEnterprise, + proTier: PRO_TIER, + maxTier: MAX_TIER, + proDailyRefresh, + maxDailyRefresh, + showProCard, + isOnPro, + isOnMax, + isOnProTier, + isOnMaxTier, + wantsIntervalSwitch, + usage, + usageLimitData, + organizationBillingData, + billingOrganizationId, + isTeamAdmin, + shouldUseOrganizationBillingContext, + onDemandState, + canDisableOnDemand, + hasUsablePaidAccess, + isBlocked, + showBadge, + badgeConfig, + creditBalance: subscriptionData?.data?.creditBalance ?? 0, + periodEnd: subscriptionData?.data?.periodEnd ?? null, + workspaceId, + isGrandfatheredSharedWorkspace, + workspaceAdmins, + billedAccountUserId, + canManageWorkspaceKeys, + updateWorkspaceSettings, + isUpdatingWorkspace: updateWorkspaceMutation.isPending, + isBillingPortalPending: openBillingPortal.isPending, + managePlanModalOpen, + setManagePlanModalOpen, + usageLimitRef, + doUpgrade, + handleSwitchInterval, + handleToggleOnDemand, + handleBadgeClick, + onCancel, + onRestore, + openBillingPortalWindow, + upgradeOrSwitchToMax, + onUpgradeToOtherTier, + onUpgradeToCurrentTier, + refetchSubscription, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx new file mode 100644 index 0000000000..71b726e908 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' +import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade' + +export const metadata: Metadata = { title: 'Upgrade' } + +export default async function UpgradePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts new file mode 100644 index 0000000000..a8507773d3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts @@ -0,0 +1,57 @@ +import type { ComponentType, SVGProps } from 'react' +import type { LucideIcon } from 'lucide-react' +import { + Clock, + HardDrive, + HeadphonesIcon, + LayoutGrid, + Server, + ShieldCheck, + Table2, + Timer, + Users, + Zap, +} from 'lucide-react' +import { SlackMonoIcon } from '@/components/icons' + +export interface PlanFeature { + icon: LucideIcon | ComponentType> + text: string +} + +export const PRO_PLAN_FEATURES: PlanFeature[] = [ + { icon: Zap, text: '150 runs/min (sync)' }, + { icon: Clock, text: '1,000 runs/min (async)' }, + { icon: Timer, text: '50 min sync run limit' }, + { icon: HardDrive, text: '50GB file storage' }, + { icon: Table2, text: '25 tables · 5,000 rows each' }, + { icon: LayoutGrid, text: 'Up to 3 personal workspaces' }, +] + +export const MAX_PLAN_FEATURES: PlanFeature[] = [ + { icon: Zap, text: '300 runs/min (sync)' }, + { icon: Clock, text: '2,500 runs/min (async)' }, + { icon: Timer, text: '50 min sync run limit' }, + { icon: HardDrive, text: '500GB file storage' }, + { icon: Table2, text: '25 tables · 5,000 rows each' }, + { icon: LayoutGrid, text: 'Up to 10 personal workspaces' }, +] + +export const TEAM_INLINE_FEATURES: PlanFeature[] = [ + { icon: Users, text: 'Shared credit pool' }, + { icon: Zap, text: 'Max plan rate limits' }, + { icon: HardDrive, text: 'Max plan file storage' }, + { icon: Table2, text: '100 tables · 10,000 rows each' }, + { icon: LayoutGrid, text: 'Unlimited shared workspaces' }, + { icon: ShieldCheck, text: 'Access controls' }, + { icon: SlackMonoIcon, text: 'Dedicated Slack channel' }, +] + +export const ENTERPRISE_PLAN_FEATURES: PlanFeature[] = [ + { icon: Zap, text: 'Custom infra limits' }, + { icon: LayoutGrid, text: 'Unlimited shared workspaces' }, + { icon: Server, text: 'SSO' }, + { icon: ShieldCheck, text: 'SOC2' }, + { icon: HardDrive, text: 'Self hosting' }, + { icon: HeadphonesIcon, text: 'Dedicated support' }, +] diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/subscription-permissions.ts new file mode 100644 index 0000000000..bf07d45713 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/subscription-permissions.ts @@ -0,0 +1,82 @@ +export interface SubscriptionPermissions { + canUpgradeToPro: boolean + canUpgradeToTeam: boolean + canViewEnterprise: boolean + canManageTeam: boolean + canEditUsageLimit: boolean + canCancelSubscription: boolean + showTeamMemberView: boolean + showUpgradePlans: boolean + isEnterpriseMember: boolean + canViewUsageInfo: boolean +} + +export interface SubscriptionState { + isFree: boolean + isPro: boolean + isTeam: boolean + isEnterprise: boolean + isPaid: boolean + /** + * True when the subscription's `referenceId` is an organization. Source + * of truth for scope-based decisions — `pro_*` plans that have been + * transferred to an org are org-scoped even though `isTeam` is false. + */ + isOrgScoped: boolean + plan: string + status: string +} + +export interface UserRole { + isTeamAdmin: boolean + userRole: string +} + +export function getSubscriptionPermissions( + subscription: SubscriptionState, + userRole: UserRole +): SubscriptionPermissions { + const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription + const { isTeamAdmin } = userRole + + // Non-admin org members see the "team member" view: no edit / no cancel + // / no upgrade, pooled usage display. + const orgMemberOnly = isOrgScoped && !isTeamAdmin + const orgAdminOrSolo = !isOrgScoped || isTeamAdmin + + const isEnterpriseMember = isEnterprise && !isTeamAdmin + const canViewUsageInfo = !isEnterpriseMember + + return { + canUpgradeToPro: isFree, + canUpgradeToTeam: isFree || (isPro && !isOrgScoped), + canViewEnterprise: !isEnterprise && !orgMemberOnly, + canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise, + canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo, + canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo, + showTeamMemberView: orgMemberOnly, + showUpgradePlans: + (isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise, + isEnterpriseMember, + canViewUsageInfo, + } +} + +export function getVisiblePlans( + subscription: SubscriptionState, + userRole: UserRole +): ('pro' | 'team' | 'enterprise')[] { + const plans: ('pro' | 'team' | 'enterprise')[] = [] + const { isFree, isPro, isEnterprise, isOrgScoped } = subscription + const { isTeamAdmin } = userRole + + if (isFree) { + plans.push('pro', 'team', 'enterprise') + } else if (isPro && !isOrgScoped) { + plans.push('team', 'enterprise') + } else if (isOrgScoped && isTeamAdmin && !isEnterprise) { + plans.push('enterprise') + } + + return plans +} diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx new file mode 100644 index 0000000000..e0269d71fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -0,0 +1,318 @@ +'use client' + +import { useCallback } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' +import { ArrowLeft, Chip } from '@/components/emcn' +import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants' +import { + getDisplayPlanName, + getPlanTierCredits, + getPlanTierDollars, +} from '@/lib/billing/plan-helpers' +import { UsageHeader } from '@/app/workspace/[workspaceId]/settings/components/usage-header/usage-header' +import { UsageLimit } from '@/app/workspace/[workspaceId]/settings/components/usage-limit' +import { + BillingPeriodToggle, + BillingUsageNotificationsToggle, + CreditBalance, + ManagePlanModal, + UpgradePlanCard, +} from '@/app/workspace/[workspaceId]/upgrade/components' +import { useUpgradeState } from '@/app/workspace/[workspaceId]/upgrade/hooks' +import { + ENTERPRISE_PLAN_FEATURES, + MAX_PLAN_FEATURES, + PRO_PLAN_FEATURES, +} from '@/app/workspace/[workspaceId]/upgrade/plan-configs' +import { useFullscreenOriginStore } from '@/stores/fullscreen-origin' + +const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF' as const + +/** + * Props for {@link Upgrade}. + */ +export interface UpgradeProps { + workspaceId: string +} + +/** + * Full-screen Upgrade page. Renders the plan cards, the billing-period toggle, + * and the manage-plan modal on top of the derived state from + * {@link useUpgradeState}. All usage and billing controls live inside + * {@link ManagePlanModal} for paid customers. + */ +export function Upgrade({ workspaceId }: UpgradeProps) { + const state = useUpgradeState() + const router = useRouter() + const origin = useFullscreenOriginStore((s) => s.origin) + + const handleBack = useCallback(() => { + router.replace(origin ?? `/workspace/${workspaceId}/home`) + }, [origin, router, workspaceId]) + + if (state.isLoading) return null + + const proBanner = state.isOnPro ? 'Your plan' : undefined + const maxBanner = state.isOnMax ? 'Your plan' : undefined + const entBanner = state.subscription.isEnterprise ? 'Your plan' : undefined + + const discountPct = Math.round(ANNUAL_DISCOUNT_RATE * 100) + const proPrice = state.isAnnual + ? Math.round(state.proTier.dollars * (1 - ANNUAL_DISCOUNT_RATE)) + : state.proTier.dollars + const maxPrice = state.isAnnual + ? Math.round(state.maxTier.dollars * (1 - ANNUAL_DISCOUNT_RATE)) + : state.maxTier.dollars + const priceSubtext = state.isAnnual + ? 'per user/month, billed annually' + : 'per user/month, billed monthly' + + const organizationBilling = state.organizationBillingData?.data + + const usageHeaderCurrent = + state.subscription.isOrgScoped && organizationBilling?.totalCurrentUsage != null + ? organizationBilling.totalCurrentUsage + : state.usage.current + const usageHeaderLimit = state.subscription.isOrgScoped + ? (organizationBilling?.totalUsageLimit ?? state.usage.limit) + : !state.subscription.isFree && + (state.permissions.canEditUsageLimit || state.permissions.showTeamMemberView) + ? state.usage.current + : state.usage.limit + + const showUsageLimit = + !state.subscription.isFree && + (state.permissions.canEditUsageLimit || state.permissions.showTeamMemberView) + + const isPaidWithUsage = state.subscription.isPaid && state.permissions.canViewUsageInfo + const showStripeActions = + state.subscription.isPaid && + !state.permissions.showTeamMemberView && + !state.permissions.isEnterpriseMember + const showBilledAccount = state.isTeamAdmin && state.isGrandfatheredSharedWorkspace + const showPeriodEnd = + state.subscription.isPaid && + !!state.periodEnd && + !state.permissions.showTeamMemberView && + !state.permissions.isEnterpriseMember + + const billingDetailsUsageHeader = state.permissions.canViewUsageInfo ? ( + {}} + /> + ) : undefined + } + /> + ) : ( +
    + + {getDisplayPlanName(state.subscription.plan)} + +
    + ) + + const billingDetailsCreditBalance = isPaidWithUsage ? ( + state.refetchSubscription()} + /> + ) : undefined + + const billingDetailsNotifications = isPaidWithUsage ? ( + + ) : undefined + + return ( +
    +
    + + Back + +
    + +
    +
    +
    +

    + Plans that grow with you +

    + {state.showUpgradePlans && ( + + )} +
    + + {state.showUpgradePlans && ( + <> +
    + f.text)} + buttonText={ + state.isOnPro + ? 'Manage plan' + : state.isOnProTier && state.wantsIntervalSwitch + ? `Switch to ${state.isAnnual ? 'Annual' : 'Monthly'}` + : 'Get started' + } + onButtonClick={ + state.isOnPro + ? () => state.setManagePlanModalOpen(true) + : state.isOnProTier && state.wantsIntervalSwitch + ? () => + state + .handleSwitchInterval(state.isAnnual ? 'year' : 'month') + .catch((e) => alert(getErrorMessage(e, 'Failed to switch interval'))) + : () => state.doUpgrade('pro', state.proTier.credits) + } + highlighted={state.isOnPro} + bannerText={proBanner} + /> + + f.text)} + buttonText={ + state.isOnMax + ? 'Manage plan' + : state.isOnMaxTier && state.wantsIntervalSwitch + ? `Switch to ${state.isAnnual ? 'Annual' : 'Monthly'}` + : state.subscription.isFree + ? 'Get started' + : 'Upgrade' + } + onButtonClick={ + state.isOnMax + ? () => state.setManagePlanModalOpen(true) + : state.isOnMaxTier && state.wantsIntervalSwitch + ? () => + state + .handleSwitchInterval(state.isAnnual ? 'year' : 'month') + .catch((e) => alert(getErrorMessage(e, 'Failed to switch interval'))) + : state.subscription.isPaid + ? () => state.upgradeOrSwitchToMax() + : () => state.doUpgrade('pro', state.maxTier.credits) + } + highlighted={state.isOnMax} + bannerText={maxBanner} + /> + + f.text)} + buttonText='Talk to sales' + onButtonClick={() => window.open(TYPEFORM_ENTERPRISE_URL, '_blank')} + highlighted={state.subscription.isEnterprise} + bannerText={entBanner} + /> +
    + + )} +
    +
    + + { + await state.handleSwitchInterval(interval) + state.setManagePlanModalOpen(false) + }} + onUpgradeToOtherTier={state.onUpgradeToOtherTier} + onUpgradeToCurrentTier={state.onUpgradeToCurrentTier} + onCancel={state.onCancel} + onRestore={state.onRestore} + billingDetails={ + state.subscription.isPaid || state.isTeamAdmin + ? { + usageHeader: billingDetailsUsageHeader, + creditBalance: billingDetailsCreditBalance, + notificationsToggle: billingDetailsNotifications, + periodEnd: showPeriodEnd ? state.periodEnd : null, + isCancelledAtPeriodEnd: state.isCancelledAtPeriodEnd, + showStripeActions, + isBillingPortalPending: state.isBillingPortalPending, + onOpenBillingPortal: state.openBillingPortalWindow, + showBilledAccount, + workspaceAdmins: state.workspaceAdmins.map((a) => ({ + userId: a.userId, + email: a.email, + })), + billedAccountUserId: state.billedAccountUserId, + canManageWorkspaceKeys: state.canManageWorkspaceKeys, + isUpdatingWorkspace: state.isUpdatingWorkspace, + onChangeBilledAccount: async (userId: string) => { + await state.updateWorkspaceSettings({ billedAccountUserId: userId }) + }, + } + : undefined + } + /> +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 77d9ffbc7b..d4febb0cc7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -3,6 +3,7 @@ import { escapeRegex, filterOutContext, isContextAlreadySelected, + SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { ChatContext } from '@/stores/panel' @@ -74,8 +75,13 @@ export function useContextManagement({ message, initialContexts }: UseContextMan // — whitespace, end-of-string, or punctuation — so `@Slack.` and // `@Slack,` survive the sync. A strict `(\s|$)` here would strip // contexts whenever the user ends a sentence with a mention. - const isSlashCommand = c.kind === 'slash_command' - const prefix = isSlashCommand ? '/' : '@' + // Skills store a wide EM SPACE sentinel (SKILL_CHIP_TRIGGER) as their + // trigger so the chip icon fits; slash commands keep '/'; everything + // else uses '@'. The sentinel is itself a whitespace character, but the + // `(^|\s)` boundary still matches the (regular) space or start that + // precedes it, then the literal sentinel. + const prefix = + c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@' const tokenPattern = new RegExp( `(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(?![A-Za-z0-9_])` ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts index fc82f845db..605f1fe59d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react' import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu' +import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { ChatContext } from '@/stores/panel' interface UseMentionTokensProps { @@ -11,8 +12,6 @@ interface UseMentionTokensProps { mentionMenu: ReturnType /** Callback to update message */ setMessage: (message: string) => void - /** Callback to update selected contexts */ - setSelectedContexts: React.Dispatch> } /** @@ -25,25 +24,29 @@ export interface MentionRange { } /** - * Custom hook to manage mention token ranges and manipulation. - * Handles computing mention ranges, deleting tokens, and preventing editing inside tokens. + * Custom hook for the TEXT side of mention tokens: computing token ranges, + * locating the range at a caret, and deleting a token's text atomically. + * + * Context lifetime is intentionally NOT managed here — `useContextManagement`'s + * sync effect owns it, removing a context exactly when its last matching token + * disappears from the message. That keeps duplicate-label chips (two `@sub` for + * the same resource) correct: deleting one token leaves the other, so the + * shared context survives. * * @param props - Configuration object - * @returns Mention token utilities + * @returns Mention token text utilities */ export function useMentionTokens({ message, selectedContexts, mentionMenu, setMessage, - setSelectedContexts, }: UseMentionTokensProps) { /** - * Memoized mention ranges - computed once when message or selectedContexts change. - * This prevents expensive O(n×m) string searches from running on every keystroke - * when other callbacks access the ranges. + * All mention token ranges in the message, recomputed only when the message or + * selected contexts change — so the O(n×m) scan never runs on every keystroke. */ - const memoizedMentionRanges = useMemo((): MentionRange[] => { + const mentionRanges = useMemo((): MentionRange[] => { const ranges: MentionRange[] = [] if (!message || selectedContexts.length === 0) return ranges @@ -57,8 +60,12 @@ export function useMentionTokens({ for (const label of uniqueLabels) { // Find matching context to determine if it's a slash command const matchingContext = selectedContexts.find((c) => c.label === label) - const isSlashCommand = matchingContext?.kind === 'slash_command' - const prefix = isSlashCommand ? '/' : '@' + const prefix = + matchingContext?.kind === 'skill' + ? SKILL_CHIP_TRIGGER + : matchingContext?.kind === 'slash_command' + ? '/' + : '@' // Check for token at the very start of the message (no leading space) const tokenAtStart = `${prefix}${label} ` @@ -92,50 +99,22 @@ export function useMentionTokens({ }, [message, selectedContexts]) /** - * Finds a mention range containing the given position - */ - const computeMentionRanges = useCallback( - (): MentionRange[] => memoizedMentionRanges, - [memoizedMentionRanges] - ) - - /** - * Finds a mention range containing the given position. - * Uses memoized ranges directly for better performance. + * Finds the mention range strictly containing a caret position, if any. * - * @param pos - Position to check - * @returns Mention range if found, undefined otherwise + * @param pos - Caret position to check + * @returns The containing range, or `undefined` */ const findRangeContaining = useCallback( (pos: number): MentionRange | undefined => { - return memoizedMentionRanges.find((r) => pos > r.start && pos < r.end) + return mentionRanges.find((r) => pos > r.start && pos < r.end) }, - [memoizedMentionRanges] + [mentionRanges] ) /** - * Removes contexts for mention tokens that overlap with a text selection. - * Uses memoized ranges directly for better performance. - * - * @param selStart - Selection start position - * @param selEnd - Selection end position - */ - const removeContextsInSelection = useCallback( - (selStart: number, selEnd: number) => { - const overlappingRanges = memoizedMentionRanges.filter( - (r) => !(selEnd <= r.start || selStart >= r.end) - ) - - if (overlappingRanges.length > 0) { - const labelsToRemove = new Set(overlappingRanges.map((r) => r.label)) - setSelectedContexts((prev) => prev.filter((c) => !c.label || !labelsToRemove.has(c.label))) - } - }, - [memoizedMentionRanges, setSelectedContexts] - ) - - /** - * Deletes a single mention range and its context (for atomic token deletion) + * Atomically deletes a single mention token's text. The context is left to + * `useContextManagement`'s sync effect, which prunes it only once no matching + * token remains — so deleting one of two duplicate chips keeps the other. * * @param range - The range to delete */ @@ -146,46 +125,26 @@ export function useMentionTokens({ const before = message.slice(0, range.start) const after = message.slice(range.end) - const next = `${before}${after}`.replace(/\s{2,}/g, ' ') + // Collapse only the space seam the removal creates (a leading + trailing + // space meeting), never unrelated double-spaces elsewhere in the message. + const next = + before.endsWith(' ') && after.startsWith(' ') + ? `${before}${after.replace(/^ +/, '')}` + : `${before}${after}` setMessage(next) - setSelectedContexts((prev) => prev.filter((c) => c.label !== range.label)) - // Set cursor position immediately after state update setTimeout(() => { textarea.setSelectionRange(range.start, range.start) textarea.focus() }, 0) }, - [message, setMessage, mentionMenu.textareaRef, setSelectedContexts] - ) - - /** - * Handles cut operations to remove contexts for cut mention tokens - * - * @param e - Clipboard event - */ - const handleCut = useCallback( - (e: React.ClipboardEvent) => { - const textarea = mentionMenu.textareaRef.current - if (!textarea) return - - const selStart = textarea.selectionStart ?? 0 - const selEnd = textarea.selectionEnd ?? selStart - const selectionLength = Math.abs(selEnd - selStart) - - if (selectionLength > 0) { - removeContextsInSelection(selStart, selEnd) - } - }, - [mentionMenu.textareaRef, removeContextsInSelection] + [message, setMessage, mentionMenu.textareaRef] ) return { - computeMentionRanges, + mentionRanges, findRangeContaining, - removeContextsInSelection, deleteRange, - handleCut, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 225799b3a0..04dbf433d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -6,6 +6,25 @@ import { import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data' import type { ChatContext } from '@/stores/panel' +/** + * Wide fixed-advance glyph used as the stored trigger for skill chips so the + * centered icon fits its slot exactly like '@' does (a narrow '/' is too thin). + * The user still TYPES '/'; this replaces it on confirm. EM SPACE has a ~1em + * advance, closely matching the 12px chip icon. + */ +export const SKILL_CHIP_TRIGGER = '\u2003' + +/** + * Converts the stored EM SPACE skill trigger back to a literal '/' for any + * plain-text surface that leaves the editor — the submitted message and the + * clipboard — so skills read as `/skill-name` exactly as they did before the + * sentinel existed. Skills themselves travel via contexts, so this is purely + * cosmetic for the text. + */ +export function restoreSkillTriggerText(text: string): string { + return text.replaceAll(SKILL_CHIP_TRIGGER, '/') +} + /** * Escapes special regex characters in a string * @param value - String to escape @@ -25,11 +44,23 @@ export function extractContextTokens(contexts: ChatContext[]): string[] { return contexts .filter((c) => c.kind !== 'current_workflow' && c.label) .map((c) => { - const prefix = c.kind === 'slash_command' ? '/' : '@' + const prefix = + c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@' return `${prefix}${c.label}` }) } +/** + * Inverse of {@link extractContextTokens}'s prefixing: strips a leading mention + * trigger (`@`, `/`, or the skill EM-SPACE sentinel) from a token, yielding the + * bare context label. Kept beside `extractContextTokens` so the set of trigger + * glyphs lives in exactly one place. + */ +export function stripMentionTrigger(token: string): string { + const first = token.charAt(0) + return first === '@' || first === '/' || first === SKILL_CHIP_TRIGGER ? token.slice(1) : token +} + /** * Mention range for text highlighting */ @@ -153,6 +184,7 @@ type FileContext = Extract type LogsContext = Extract type IntegrationContext = Extract type SlashCommandContext = Extract +type SkillContext = Extract /** * Checks if two contexts of the same kind are equal by their ID fields. @@ -208,6 +240,10 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean const ctx = context as SlashCommandContext return c.command === ctx.command } + case 'skill': { + const ctx = context as SkillContext + return c.skillId === ctx.skillId + } default: return false } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 45aa9015bd..fb20c9dbdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -16,7 +16,7 @@ import { parseProvider, } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' -import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal' +import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' @@ -472,25 +472,29 @@ export function CredentialSelector({ )} {showConnectModal && ( - setShowConnectModal(false)} + origin='workflow' + open={showConnectModal} + onOpenChange={(open) => !open && setShowConnectModal(false)} provider={provider} serviceId={serviceId} + providerId={effectiveProviderId} + requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} workspaceId={workspaceId} workflowId={activeWorkflowId || ''} - credentialCount={credentials.length} /> )} {showOAuthModal && ( - { - consumeOAuthReturnContext() - setShowOAuthModal(false) + open={showOAuthModal} + onOpenChange={(open) => { + if (!open) { + consumeOAuthReturnContext() + setShowOAuthModal(false) + } }} provider={provider} toolName={getProviderName(provider)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index 5086737670..1d4093bfac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -15,7 +15,7 @@ import { parseProvider, } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' -import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal' +import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useWorkspaceCredential } from '@/hooks/queries/credentials' @@ -264,25 +264,29 @@ export function ToolCredentialSelector({ )} {showConnectModal && ( - setShowConnectModal(false)} + origin='workflow' + open={showConnectModal} + onOpenChange={(open) => !open && setShowConnectModal(false)} provider={provider} serviceId={serviceId} + providerId={effectiveProviderId} + requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} workspaceId={workspaceId} workflowId={effectiveWorkflowId || ''} - credentialCount={credentials.length} /> )} {showOAuthModal && ( - { - consumeOAuthReturnContext() - setShowOAuthModal(false) + open={showOAuthModal} + onOpenChange={(open) => { + if (!open) { + consumeOAuthReturnContext() + setShowOAuthModal(false) + } }} provider={provider} toolName={getProviderName(provider)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 22ea227228..ee4b43ae03 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -23,7 +23,7 @@ import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/creden import type { OAuthProvider } from '@/lib/oauth' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' -import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal' +import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { CommandList, @@ -4229,12 +4229,14 @@ const WorkflowContent = React.memo( {(!embedded || sandbox) && } {!embedded && !sandbox && oauthModal && ( - { - consumeOAuthReturnContext() - setOauthModal(null) + open={true} + onOpenChange={(open) => { + if (!open) { + consumeOAuthReturnContext() + setOauthModal(null) + } }} provider={oauthModal.provider} toolName={oauthModal.providerName} diff --git a/apps/sim/components/emcn/components/chip-switch/chip-switch.tsx b/apps/sim/components/emcn/components/chip-switch/chip-switch.tsx new file mode 100644 index 0000000000..aafe834284 --- /dev/null +++ b/apps/sim/components/emcn/components/chip-switch/chip-switch.tsx @@ -0,0 +1,97 @@ +'use client' + +import type { ComponentType, ReactNode } from 'react' +import { chipVariants } from '@/components/emcn/components/chip/chip' +import { cn } from '@/lib/core/utils/cn' + +/** + * One segment in a {@link ChipSwitch}. `label` accepts a `ReactNode` so callers + * can render colored accents (e.g. a discount badge) inline. + */ +export interface ChipSwitchOption { + /** The value associated with this option — passed to `onChange` on select. */ + value: T + /** Visible label content; `ReactNode` allows inline badges or colored spans. */ + label: ReactNode + /** Optional leading icon rendered before the label. */ + icon?: ComponentType<{ className?: string }> +} + +/** + * Props for {@link ChipSwitch}. + */ +export interface ChipSwitchProps { + /** Ordered list of options to render as segments. */ + options: ChipSwitchOption[] + /** Currently selected value. */ + value: T + /** Invoked with the next selection when a segment is clicked. */ + onChange: (value: T) => void + /** Optional accessible label for the radio group. */ + 'aria-label'?: string + /** Extra classes merged onto the outer container. */ + className?: string +} + +/** + * A pill-shaped segmented switch built from the chip language: each segment is + * a {@link chipVariants}-styled button — `border-shadow` when active, `ghost` + * when not — so text size, padding, height, and rounding match {@link Chip} + * exactly. The active segment lifts via `--border-1` + `--shadow-subtle`. + * + * @example + * Annual-20% }, + * { value: 'monthly', label: 'Monthly' }, + * ]} + * /> + */ +export function ChipSwitch({ + options, + value, + onChange, + 'aria-label': ariaLabel, + className, +}: ChipSwitchProps) { + return ( +
    + {options.map((option) => { + const Icon = option.icon + const isActive = option.value === value + return ( + + ) + })} +
    + ) +} diff --git a/apps/sim/components/emcn/components/chip-switch/index.ts b/apps/sim/components/emcn/components/chip-switch/index.ts new file mode 100644 index 0000000000..47f3d6c994 --- /dev/null +++ b/apps/sim/components/emcn/components/chip-switch/index.ts @@ -0,0 +1,5 @@ +export { + ChipSwitch, + type ChipSwitchOption, + type ChipSwitchProps, +} from './chip-switch' diff --git a/apps/sim/components/emcn/components/chip-tag/chip-tag.tsx b/apps/sim/components/emcn/components/chip-tag/chip-tag.tsx new file mode 100644 index 0000000000..3e06bb6167 --- /dev/null +++ b/apps/sim/components/emcn/components/chip-tag/chip-tag.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { HTMLAttributes, ReactNode } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/core/utils/cn' + +/** + * Small inline tag/badge — 20px tall, soft tinted surface with a 1px inset ring + * and matching tinted text. Designed for compact in-line accents (discount + * pills, status counters, sub-labels next to titles). + * + * @remarks + * Variants color the surface, ring, and text in concert. The surface uses a + * pale tint, the ring uses a slightly stronger tint, and the text uses the + * saturated hue. `gray` falls back to workspace tokens for neutral contexts. + */ +const chipTagVariants = cva( + 'inline-flex h-5 items-center gap-[3px] rounded-md px-1 text-sm leading-5', + { + variants: { + variant: { + blue: 'bg-[#E5EEFF] text-[#266DF0] shadow-[inset_0_0_0_1px_#D6E5FF]', + green: 'bg-[#E0FCED] text-[#02AD6E] shadow-[inset_0_0_0_1px_#CBF7E1]', + red: 'bg-[#FFEBEB] text-[#ED3B3B] shadow-[inset_0_0_0_1px_#FFD1D1]', + yellow: 'bg-[#FFF3CC] text-[#CF8300] shadow-[inset_0_0_0_1px_#FFE59E]', + purple: 'bg-[#F5F0FF] text-[#6238B5] shadow-[inset_0_0_0_1px_#E8DDFE]', + gray: 'bg-[var(--surface-5)] text-[var(--text-secondary)] shadow-[inset_0_0_0_1px_var(--border-1)]', + }, + }, + defaultVariants: { variant: 'blue' }, + } +) + +/** + * Props for {@link ChipTag}. + */ +export interface ChipTagProps + extends Omit, 'children'>, + VariantProps { + /** Tag content — typically a short label, number, or percentage. */ + children: ReactNode +} + +/** + * A compact tinted tag in the chip language. + * + * @example + * -20% + * New + */ +export function ChipTag({ variant, className, children, ...props }: ChipTagProps) { + return ( + + {children} + + ) +} + +export { chipTagVariants } diff --git a/apps/sim/components/emcn/components/chip-tag/index.ts b/apps/sim/components/emcn/components/chip-tag/index.ts new file mode 100644 index 0000000000..079943103c --- /dev/null +++ b/apps/sim/components/emcn/components/chip-tag/index.ts @@ -0,0 +1 @@ +export { ChipTag, type ChipTagProps, chipTagVariants } from './chip-tag' diff --git a/apps/sim/components/emcn/components/chip/chip.tsx b/apps/sim/components/emcn/components/chip/chip.tsx index c17fb2a4d1..54366ee371 100644 --- a/apps/sim/components/emcn/components/chip/chip.tsx +++ b/apps/sim/components/emcn/components/chip/chip.tsx @@ -21,7 +21,8 @@ import { cn } from '@/lib/core/utils/cn' * * @remarks * Variants: `ghost` (transparent → `--surface-active` hover), `filled` (`--surface-active` → `--surface-6` hover), - * `primary` (inverse surface), `destructive` (red surface with red border). + * `primary` (inverse surface), `destructive` (red surface with red border), + * `border-shadow` (raised card-like surface with `--border-1` border and `--shadow-card`). * `active` renders ghost/filled in their selected state. `fullWidth` swaps `inline-flex` for block-level `flex`. * `flush` removes the default horizontal margin (`mx-0.5`) used by chip clusters — use when a single chip sits * inside its own layout slot (grid cell, table cell) where the cluster spacing is unwanted. @@ -38,6 +39,8 @@ const chipVariants = cva( 'bg-[var(--text-primary)] text-[var(--text-inverse)] hover-hover:bg-[var(--text-body)] hover-hover:text-[var(--text-inverse)] dark:bg-white dark:text-[var(--bg)] dark:hover-hover:bg-[var(--text-secondary)] dark:hover-hover:text-[var(--bg)]', destructive: 'border border-[#E54C4C] bg-[#FF5454] text-white hover-hover:border-[#D63535] hover-hover:bg-[#EE3B3B]', + 'border-shadow': + 'bg-[var(--surface-2)] shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-3)]', }, active: { true: '', false: '' }, fullWidth: { true: 'flex', false: 'inline-flex' }, diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index cd0a68541e..a7e5e1e730 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -40,6 +40,12 @@ export { type ChipModalHeaderProps, type ChipModalProps, } from './chip-modal/chip-modal' +export { + ChipSwitch, + type ChipSwitchOption, + type ChipSwitchProps, +} from './chip-switch/chip-switch' +export { ChipTag, type ChipTagProps, chipTagVariants } from './chip-tag/chip-tag' export { CODE_LINE_HEIGHT_PX, Code, diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index 7e2accabcb..e26abe0e68 100644 --- a/apps/sim/components/emcn/icons/index.ts +++ b/apps/sim/components/emcn/icons/index.ts @@ -80,6 +80,7 @@ export { Server } from './server' export { Settings } from './settings' export { ShieldCheck } from './shield-check' export { Sim } from './sim' +export { Slash } from './slash' export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' export { Table } from './table' diff --git a/apps/sim/components/emcn/icons/slash.tsx b/apps/sim/components/emcn/icons/slash.tsx new file mode 100644 index 0000000000..92c269aba7 --- /dev/null +++ b/apps/sim/components/emcn/icons/slash.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' + +/** + * Slash icon component - forward slash "/" glyph for the skills trigger. + * Matches the weight and optical box of Plus/Paperclip (viewBox -1 -2 24 24, + * strokeWidth 1.55, round caps). Centered at (10.25, 10.25) like Plus. + * @param props - SVG properties including className, fill, etc. + */ +export function Slash(props: SVGProps) { + return ( + + ) +} diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index d201ec0672..95a09fe3e7 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -76,6 +76,30 @@ export function prefetchSubscriptionData(queryClient: QueryClient) { }) } +/** + * Prefetch the billing queries the Upgrade page gates on: the + * organization-scoped subscription variant (`includeOrg: true`, a different + * cache key than the credits chip's `false` variant) and the usage-limit + * metadata. Use on hover to warm both before navigating to `/upgrade`. + * + * Org-scoped subscribers additionally gate on the organization-billing query, + * which is intentionally not prewarmed here: its key depends on the resolved + * billing organization id, which is only derivable after the subscription and + * workspace queries land, so it cannot be warmed at hover time. + */ +export function prefetchUpgradeBillingData(queryClient: QueryClient) { + queryClient.prefetchQuery({ + queryKey: subscriptionKeys.user(true), + queryFn: ({ signal }) => fetchSubscriptionData(true, signal), + staleTime: 5 * 60 * 1000, + }) + queryClient.prefetchQuery({ + queryKey: subscriptionKeys.usage(), + queryFn: ({ signal }) => fetchUsageLimitData(signal), + staleTime: 30 * 1000, + }) +} + /** * Fetch user usage limit metadata * Note: This endpoint returns limit information (currentLimit, minimumLimit, canEdit, etc.) diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 5f783973fc..cdb700fdb0 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' @@ -257,6 +258,21 @@ async function fetchWorkspaceSettings(workspaceId: string, signal?: AbortSignal) } } +/** + * Prefetch a workspace's settings (and permissions) into the cache. Use on + * hover to warm data before navigating to a settings-style route. + * @param queryClient - The active QueryClient + * @param workspaceId - The workspace ID to prefetch settings for + */ +export function prefetchWorkspaceSettings(queryClient: QueryClient, workspaceId: string) { + if (!workspaceId) return + queryClient.prefetchQuery({ + queryKey: workspaceKeys.settings(workspaceId), + queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal), + staleTime: 30 * 1000, + }) +} + /** * Fetches workspace settings including permissions. * @param workspaceId - The workspace ID to fetch settings for diff --git a/apps/sim/lib/integrations/index.ts b/apps/sim/lib/integrations/index.ts index 2d0927cdf3..b3b879fb49 100644 --- a/apps/sim/lib/integrations/index.ts +++ b/apps/sim/lib/integrations/index.ts @@ -60,6 +60,11 @@ export const POPULAR_WORKFLOWS: readonly PopularWorkflow[] = (() => { })() export { blockTypeToIconMap } from '@/lib/integrations/icon-mapping' +export { + type OAuthServiceMatch, + resolveOAuthServiceForIntegration, + resolveOAuthServiceForSlug, +} from '@/lib/integrations/oauth-service' export type { AuthType, FAQItem, Integration } from '@/lib/integrations/types' export { getAllBlockMeta, getBlockMeta, getTemplatesForBlock } from '@/blocks/registry' export type { BlockMeta, BlockTemplate } from '@/blocks/types' diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts new file mode 100644 index 0000000000..39a6d63573 --- /dev/null +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -0,0 +1,75 @@ +import type { ComponentType } from 'react' +import integrationsJson from '@/lib/integrations/integrations.json' +import type { Integration } from '@/lib/integrations/types' +import { OAUTH_PROVIDERS } from '@/lib/oauth' +import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' + +const INTEGRATIONS_DATA: readonly Integration[] = integrationsJson as readonly Integration[] + +/** + * Shape returned from resolving an integration to its OAuth service entry in + * `OAUTH_PROVIDERS`. Carries the metadata needed to mount `ConnectOAuthModal` + * (and optionally `ConnectServiceAccountModal`) for the integration. + */ +export interface OAuthServiceMatch { + providerId: string + requiredScopes: string[] + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** + * When set, the matched OAuth service also exposes a service-account flow + * (e.g. Google services with `google-service-account`, Atlassian services + * with `atlassian-service-account`). Callers may surface this as a secondary + * connect option. + */ + serviceAccountProviderId?: ServiceAccountProviderId +} + +/** + * Narrows the runtime `OAuthServiceConfig.serviceAccountProviderId` string to + * the {@link ServiceAccountProviderId} union. Anything outside the union is + * unsupported by `ConnectServiceAccountModal` and is silently ignored. + */ +function asServiceAccountProviderId( + value: string | undefined +): ServiceAccountProviderId | undefined { + if (value === 'google-service-account' || value === 'atlassian-service-account') return value + return undefined +} + +/** + * Looks up the OAuth service entry whose display name matches the integration + * (case-insensitive). Returns `null` for non-OAuth integrations or when no + * matching service is registered in `OAUTH_PROVIDERS`. + */ +export function resolveOAuthServiceForIntegration( + integration: Integration +): OAuthServiceMatch | null { + if (integration.authType !== 'oauth') return null + const target = integration.name.toLowerCase() + for (const provider of Object.values(OAUTH_PROVIDERS)) { + for (const service of Object.values(provider.services)) { + if (service.name.toLowerCase() === target) { + return { + providerId: service.providerId, + requiredScopes: service.scopes ?? [], + serviceName: service.name, + serviceIcon: service.icon as ComponentType<{ className?: string }>, + serviceAccountProviderId: asServiceAccountProviderId(service.serviceAccountProviderId), + } + } + } + } + return null +} + +/** + * Resolves the integration entry for a catalog slug, then derives its OAuth + * service match. Returns `null` when the slug is unknown or the matching + * integration is not an OAuth integration. + */ +export function resolveOAuthServiceForSlug(slug: string): OAuthServiceMatch | null { + const integration = INTEGRATIONS_DATA.find((entry) => entry.slug === slug) + if (!integration) return null + return resolveOAuthServiceForIntegration(integration) +} diff --git a/apps/sim/stores/fullscreen-origin.ts b/apps/sim/stores/fullscreen-origin.ts new file mode 100644 index 0000000000..1f65118414 --- /dev/null +++ b/apps/sim/stores/fullscreen-origin.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface FullscreenOriginState { + /** Pathname active immediately before the current fullscreen route, or `null` on a direct load. */ + origin: string | null + /** Records the page a fullscreen route was launched from. */ + setOrigin: (origin: string | null) => void +} + +const initialState = { origin: null as string | null } + +/** + * Holds the pathname a fullscreen route (e.g. `/upgrade`) was launched from. + * {@link WorkspaceChrome} writes the last non-fullscreen pathname it observes, + * so any trigger that merely pushes a fullscreen route gets correct + * return-to-origin without per-call-site wiring. + */ +export const useFullscreenOriginStore = create()( + devtools( + (set) => ({ + ...initialState, + setOrigin: (origin) => set({ origin }, false, 'setOrigin'), + }), + { name: 'fullscreen-origin-store' } + ) +) diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 15631aa04f..05012aee9f 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -34,3 +34,4 @@ export type ChatContext = | { kind: 'docs'; label: string } | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } + | { kind: 'skill'; skillId: string; label: string }