This commit is contained in:
Emir Karabeg
2026-06-01 11:36:19 -07:00
parent 85265a697c
commit 6a7e7344fa
68 changed files with 4172 additions and 1109 deletions
@@ -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>
): 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> | 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<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(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<HTMLDivElement>) => {
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 (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={title}>
<ChipModalHeader icon={ProviderIcon} onClose={handleClose}>
{title}
</ChipModalHeader>
<ChipModalBody onKeyDown={handleBodyKeyDown}>
{!isConnect && (
<p className='text-[var(--text-tertiary)] text-caption'>
The "{props.toolName}" tool requires access to your account.
</p>
)}
{isConnect && (
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={(value) => {
setDisplayName(value)
if (validationError) setValidationError(null)
}}
placeholder='Integration name'
autoComplete='off'
required
error={displayNameError}
/>
)}
{isConnect && (
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
)}
{displayScopes.length > 0 && (
<ChipModalField type='custom' title='Permissions requested'>
<InfoCard>
<InfoCardList>
{displayScopes.map((scope) => (
<InfoCardItem key={scope}>
<span className='flex items-center gap-2'>
{getScopeDescription(scope)}
{!isConnect && newScopesSet.has(scope) && (
<Badge variant='amber' size='sm'>
New
</Badge>
)}
</span>
</InfoCardItem>
))}
</InfoCardList>
</InfoCard>
</ChipModalField>
)}
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter>
<Chip variant='ghost' onClick={handleClose} disabled={isPending}>
Cancel
</Chip>
<Chip variant='primary' onClick={handleConnect} disabled={isDisabled}>
{isPending ? 'Connecting...' : 'Connect'}
</Chip>
</ChipModalFooter>
</ChipModal>
)
}
@@ -0,0 +1,4 @@
export {
ConnectOAuthModal,
type ConnectOAuthModalProps,
} from './connect-oauth-modal'
@@ -1 +0,0 @@
export { getDefaultCredentialName, OAuthModal, type OAuthModalProps } from './oauth-modal'
@@ -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")
})
})
@@ -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> | 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<string | null>(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 (
<Modal open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<ModalContent size='md'>
<ModalHeader>Connect {providerName}</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Connect your {providerName} account to grant access
</ModalDescription>
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex size-[40px] flex-shrink-0 items-center justify-center rounded-lg bg-[var(--surface-5)]'>
<ProviderIcon className='size-[18px]' />
</div>
<div className='flex-1'>
<p className='font-medium text-[var(--text-primary)] text-small'>
Connect your {providerName} account
</p>
<p className='text-[var(--text-tertiary)] text-caption'>{subtitle}</p>
</div>
</div>
{displayScopes.length > 0 && (
<div className='rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)]'>
<div className='border-[var(--border-1)] border-b px-3.5 py-2.5'>
<h4 className='font-medium text-[var(--text-primary)] text-caption'>
Permissions requested
</h4>
</div>
<ul className='max-h-[200px] space-y-2.5 overflow-y-auto px-3.5 py-3'>
{displayScopes.map((scope) => (
<li key={scope} className='flex items-start gap-2.5'>
<div className='mt-0.5 flex size-[16px] flex-shrink-0 items-center justify-center'>
<Check className='size-[10px] text-[var(--text-primary)]' />
</div>
<div className='flex flex-1 items-center gap-2 text-[var(--text-primary)] text-caption'>
<span>{getScopeDescription(scope)}</span>
{!isConnect && newScopesSet.has(scope) && (
<Badge variant='amber' size='sm'>
New
</Badge>
)}
</div>
</li>
))}
</ul>
</div>
)}
{isConnect && (
<div>
<Label>
Display name <span className='text-[var(--text-muted)]'>*</span>
</Label>
<Input
value={displayName}
onChange={(e) => {
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'
/>
</div>
)}
{error && <p className='text-[var(--text-error)] text-caption'>{error}</p>}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={Boolean(isPending)}>
Cancel
</Button>
<Button variant='primary' onClick={handleConnect} disabled={isConnectDisabled}>
{isPending ? 'Connecting...' : 'Connect'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
@@ -0,0 +1 @@
export { WorkspaceChrome } from './workspace-chrome'
@@ -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 (
<div className='flex min-h-0 flex-1'>
<div
className={cn(
'shrink-0 overflow-hidden transition-[width]',
SLIDE_TRANSITION,
isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
)}
aria-hidden={isFullscreen || undefined}
suppressHydrationWarning
>
<div
className={cn(
'h-full w-[var(--sidebar-width)] shrink-0 transition-transform',
SLIDE_TRANSITION,
isFullscreen && '-translate-x-full'
)}
>
<Sidebar />
</div>
</div>
<div
className={cn(
'flex min-w-0 flex-1 flex-col p-[8px] transition-[padding]',
SLIDE_TRANSITION,
!isFullscreen && 'pl-0'
)}
>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
</div>
</div>
</div>
)
}
@@ -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<ChatContextKind, ChatContextKind
docs: { label: 'Docs', renderIcon: () => null },
slash_command: { label: 'Command', renderIcon: () => null },
integration: { label: 'Integration', renderIcon: renderIntegrationTile },
skill: {
label: 'Skill',
renderIcon: ({ className }) => <AgentSkillsIcon className={className} />,
},
}
@@ -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 (
<Chip
aria-label='Manage subscription'
onClick={() => navigateToSettings({ section: 'subscription' })}
aria-label='Upgrade plan'
onClick={() => router.push(upgradeHref)}
onMouseEnter={prefetchUpgrade}
onFocus={prefetchUpgrade}
leftIcon={Credit}
>
{formatCredits(data.data.creditBalance)}
@@ -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<Action[]>(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<OAuthServiceMatch | null>(null)
const connectedProviders = useMemo(
() =>
new Set(
@@ -242,8 +257,6 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
[credentials]
)
const [actions, setActions] = useState<Action[]>(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 (
<div className='mx-auto mt-7 w-full max-w-[48rem]'>
<button
type='button'
onClick={() => setExpanded((prev) => !prev)}
onClick={() => {
setAnimationsEnabled(true)
setExpanded((prev) => !prev)
}}
aria-expanded={expanded}
className='flex items-center gap-2'
>
@@ -293,7 +310,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
/>
</button>
<Expandable expanded={expanded}>
<ExpandableContent className='mt-2'>
<ExpandableContent className={cn('mt-2', !animationsEnabled && '!animate-none')}>
<div className='flex flex-col'>
{actions.map((action, i) => {
const Icon = action.icon
@@ -318,6 +335,21 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
</div>
</ExpandableContent>
</Expandable>
{oauthTarget && workspaceId && (
<ConnectOAuthModal
mode='connect'
origin='integrations'
open
onOpenChange={(open) => {
if (!open) setOAuthTarget(null)
}}
workspaceId={workspaceId}
providerId={oauthTarget.providerId}
requiredScopes={oauthTarget.requiredScopes}
serviceName={oauthTarget.serviceName}
serviceIcon={oauthTarget.serviceIcon}
/>
)}
</div>
)
}
@@ -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:<kind>/<id>`. 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<Record<ChatContext['kind'],
* string>>` 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<Record<ChatContext['kind'], string>>
/**
* The subset of {@link ChatContext} kinds that serialize to a portable
* `sim:<kind>/<id>` 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<string, unknown>)[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 }
}
}
@@ -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'
@@ -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<typeof useAvailableResources>[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<MothershipResourceType>(['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(
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{availableResources
{visibleResources
.filter(
({ type }) =>
type !== 'workflow' &&
@@ -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<HTMLTextAreaElement | null>
/** Shared caret position restored after the dormant focus trap closes. */
pendingCursorRef: React.MutableRefObject<number | null>
/** 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<SkillsMenuHandle, SkillsMenuDropdownProps>(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<HTMLDivElement>(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<HTMLElement>(
`[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 (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: anchorPos?.left ?? 0,
top: anchorPos?.top ?? 0,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
align='start'
side='top'
sideOffset={8}
avoidCollisions
collisionPadding={8}
className='flex max-w-[min(300px,calc(100vw-32px))] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
onOpenAutoFocus={handleOpenAutoFocus}
>
<div className='min-h-0 flex-1 overflow-y-auto overscroll-none'>
{filteredSkills.length > 0 ? (
filteredSkills.map((skill, index) => {
const isActive = index === activeIndex
return (
<button
key={skill.id}
type='button'
role='menuitem'
data-filtered-idx={index}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => handleSelect(skill)}
className={cn(
'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
isActive && 'bg-[var(--surface-active)]'
)}
>
<AgentSkillsIcon />
<span>{skill.name}</span>
</button>
)
})
) : (
<div className='px-2 py-1.5 text-center font-medium text-[var(--text-tertiary)] text-caption'>
No skills
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
})
)
@@ -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<ChatContext, { kind: 'skill' }>
/**
* 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 `<sentinel>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<React.SetStateAction<ChatContext[]>>
}
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<string, SkillContext>()
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<string>()
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 }
}
@@ -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<UserInputHandle, UserInputProps>(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<HTMLDivElement>(null)
const plusMenuRef = useRef<PlusMenuHandle>(null)
const skillsMenuRef = useRef<SkillsMenuHandle>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(function Us
const pendingCursorRef = useRef<number | null>(null)
const mentionRangeRef = useRef<{ start: number; end: number } | null>(null)
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
const slashRangeRef = useRef<{ start: number; end: number } | null>(null)
const [slashQuery, setSlashQuery] = useState<string | null>(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<UserInputHandle, UserInputProps>(function Us
files.restoreAttachedFiles,
contextManagement.setSelectedContexts,
textareaRef,
integrationAutoMention.applyToText,
applyAutoMentions,
]
)
@@ -489,6 +521,51 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<HTMLTextAreaElement>) => {
const previousValue = valueRef.current
@@ -807,21 +967,45 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(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<HTMLTextAreaElement>) => {
@@ -852,6 +1037,68 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
)
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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<UserInputHandle, UserInputProps>(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<HTMLTextAreaElement>): 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<HTMLTextAreaElement>) => {
writeSanitizedClipboard(e)
},
[writeSanitizedClipboard]
)
const handleCut = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// 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<UserInputHandle, UserInputProps>(function Us
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
}
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<UserInputHandle, UserInputProps>(function Us
<ContextMentionIcon
context={matchingCtx}
workflowColor={wfId ? (workflowsById[wfId]?.color ?? null) : null}
className='absolute inset-0 m-auto h-[12px] w-[12px] translate-y-[1.25px] text-[var(--text-icon)]'
className='absolute inset-0 m-auto size-[12px] translate-y-[1.25px] text-[var(--text-icon)]'
/>
) : null
elements.push(
<span key={`mention-${i}-${range.start}-${range.end}`}>
<span className='relative'>
{/* 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. */}
<span className='invisible'>{range.token.charAt(0)}</span>
{mentionIconNode}
</span>
@@ -984,7 +1286,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(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<UserInputHandle, UserInputProps>(function Us
pendingCursorRef={pendingCursorRef}
mentionQuery={mentionQuery ?? undefined}
/>
<SkillsMenuDropdown
ref={skillsMenuRef}
skills={skills}
onSkillSelect={handleSkillSelect}
onClose={handleSkillsMenuClose}
textareaRef={textareaRef}
pendingCursorRef={pendingCursorRef}
slashQuery={slashQuery ?? undefined}
/>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -1012,13 +1324,27 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
variant='ghost'
onClick={handleFileSelectStable}
aria-label='Attach file'
className='h-[28px] w-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
className='size-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
>
<Paperclip className='h-[16px] w-[16px] text-[var(--text-icon)]' />
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Attach file</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleSlashTriggerClick}
aria-label='Skills'
className='size-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
>
<Slash className='size-[16px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Skills</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='flex items-center gap-1.5'>
{isSttSupported && <MicButton isListening={isListening} onToggle={toggleListening} />}
@@ -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 = {}) {
</div>
<div className='flex min-h-full flex-col items-center justify-center px-6 pb-[2vh]'>
<h1 className='mb-7 max-w-[48rem] text-balance font-season text-[30px] text-[var(--text-primary)]'>
What should we get done
{session?.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}?
What should we get done{firstName ? `, ${firstName}` : ''}?
</h1>
<div ref={initialViewInputRef} className='w-full'>
<UserInput
@@ -316,7 +317,7 @@ export function Home({ chatId }: HomeProps = {}) {
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={handleStopGeneration}
userId={session?.user?.id}
userId={userId}
onContextAdd={handleContextAdd}
onContextRemove={handleInitialContextRemove}
/>
@@ -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}
@@ -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,
@@ -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 (
<Suspense fallback={null}>
<Home />
</Suspense>
<Home
userName={session?.user?.name}
userId={session?.user?.id}
initialResourceId={resource ?? null}
/>
)
}
@@ -154,6 +154,7 @@ export interface ChatMessageContext {
folderId?: string
chatId?: string
blockType?: string
skillId?: string
}
export interface ChatMessage {
@@ -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
</div>
{oauthService && (
<ConnectOAuthModal
mode='connect'
origin='integrations'
open={oauthOpen}
onOpenChange={setOAuthOpen}
workspaceId={workspaceId}
@@ -1,273 +0,0 @@
'use client'
import { type ComponentType, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
Chip,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
InfoCard,
InfoCardItem,
InfoCardList,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
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
/** 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>
): 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
}
interface ConnectOAuthModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
providerId: string
requiredScopes: readonly string[]
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
}
/**
* Connect-OAuth modal mounted by any page that initiates an OAuth credential.
* Self-contained: takes the resolved provider + scopes from the caller, writes
* the integrations-origin return context, and hands off to Better Auth via
* `useConnectOAuthService`. After the redirect lands back on `window.location.href`,
* the host page's `useOAuthReturnRouter` consumes the return context.
*/
export function ConnectOAuthModal({
open,
onOpenChange,
workspaceId,
providerId,
requiredScopes,
serviceName,
serviceIcon,
}: ConnectOAuthModalProps) {
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const { data: session } = useSession()
const userName = session?.user?.name
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: 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]
)
/**
* Initialize the 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 (prefilled.current || credentialsLoading) return
prefilled.current = true
setDisplayName(defaultDisplayName(userName, serviceName, takenNames))
setDescription('')
setValidationError(null)
setSubmitError(null)
}, [open, credentialsLoading, userName, serviceName, takenNames])
const displayScopes = useMemo(
() => requiredScopes.filter((scope) => !isHiddenScope(scope)),
[requiredScopes]
)
const existingCredential = useMemo(() => {
const name = displayName.trim().toLowerCase()
if (!name || !takenNames.has(name)) return null
return (
credentials.find((row) => row.type === 'oauth' && row.displayName.toLowerCase() === name) ??
null
)
}, [credentials, displayName, takenNames])
const handleConnect = async () => {
const trimmed = displayName.trim()
if (!trimmed) {
setValidationError('Display name is required.')
return
}
setValidationError(null)
setSubmitError(null)
try {
await createDraft.mutateAsync({
workspaceId,
providerId,
displayName: trimmed,
description: description.trim() || undefined,
})
const preCount = credentials.filter(
(c) => c.type === 'oauth' && c.providerId === providerId
).length
writeOAuthReturnContext({
origin: 'integrations',
displayName: trimmed,
providerId,
preCount,
workspaceId,
requestedAt: Date.now(),
})
await connectOAuthService.mutateAsync({
providerId,
callbackURL: window.location.href,
})
} catch (err: unknown) {
const message = getErrorMessage(err, 'Failed to start OAuth connection')
setSubmitError(message)
logger.error('Failed to connect OAuth service', err)
}
}
const isPending = connectOAuthService.isPending
const isDisabled = !displayName.trim() || isPending || Boolean(existingCredential)
const displayNameError =
validationError ??
(existingCredential
? `An integration named "${existingCredential.displayName}" already exists.`
: undefined)
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={`Connect ${serviceName}`}>
<ChipModalHeader icon={serviceIcon} onClose={() => onOpenChange(false)}>
Connect {serviceName}
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={(value) => {
setDisplayName(value)
if (validationError) setValidationError(null)
}}
placeholder='Integration name'
autoComplete='off'
required
error={displayNameError}
/>
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
{displayScopes.length > 0 && (
<ChipModalField type='custom' title='Permissions requested'>
<InfoCard>
<InfoCardList>
{displayScopes.map((scope) => (
<InfoCardItem key={scope}>{getScopeDescription(scope)}</InfoCardItem>
))}
</InfoCardList>
</InfoCard>
</ChipModalField>
)}
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter>
<Chip variant='ghost' onClick={() => onOpenChange(false)}>
Cancel
</Chip>
<Chip variant='primary' onClick={handleConnect} disabled={isDisabled}>
{isPending ? 'Connecting...' : 'Connect'}
</Chip>
</ChipModalFooter>
</ChipModal>
)
}
@@ -1 +0,0 @@
export { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-oauth-modal/connect-oauth-modal'
@@ -1,8 +1,7 @@
/**
* Shared protocol for deep-linking to an integration detail page with a
* pre-opened connect modal. Owned by the integrations route; consumed by
* the detail page itself and by external entry points (e.g. suggested
* actions on the workspace home).
* the detail page's `?connect=oauth|service-account` query handler.
*/
export const CONNECT_QUERY_PARAM = 'connect' as const
@@ -11,14 +10,3 @@ export const CONNECT_MODE = {
oauth: 'oauth',
serviceAccount: 'service-account',
} as const
export type ConnectMode = (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE]
/** Builds the deep-link URL for an integration's connect flow. */
export function integrationConnectHref(
workspaceId: string,
slug: string,
mode: ConnectMode = CONNECT_MODE.oauth
): string {
return `/workspace/${workspaceId}/integrations/${slug}?${CONNECT_QUERY_PARAM}=${mode}`
}
@@ -35,18 +35,12 @@ import {
toast,
} from '@/components/emcn'
import { ArrowLeft, Check, Duplicate } from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { INTEGRATIONS } from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { getUserColor } from '@/lib/workspaces/colors'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import {
getSampleConnectedCredentials,
getSampleConnectedMembers,
PREVIEW_CONNECTED_WITH_SAMPLES,
} from '@/app/workspace/[workspaceId]/integrations/fixtures/sample-credentials'
import {
useCreateCredentialDraft,
useDeleteWorkspaceCredential,
@@ -108,9 +102,6 @@ export function ConnectedCredentialDetail({
useOAuthReturnRouter()
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
@@ -127,20 +118,14 @@ export function ConnectedCredentialDetail({
const upsertMember = useUpsertWorkspaceCredentialMember()
const removeMember = useRemoveWorkspaceCredentialMember()
const credential = useMemo<WorkspaceCredential | null>(() => {
const previewMatch = PREVIEW_CONNECTED_WITH_SAMPLES
? getSampleConnectedCredentials(workspaceId, currentUserId).find((c) => c.id === credentialId)
: undefined
if (previewMatch) return previewMatch
return credentials.find((c) => c.id === credentialId) ?? null
}, [credentials, credentialId, workspaceId, currentUserId])
const credential = useMemo<WorkspaceCredential | null>(
() => credentials.find((c) => c.id === credentialId) ?? null,
[credentials, credentialId]
)
const { data: realMembers = [], isPending: realMembersLoading } = useWorkspaceCredentialMembers(
const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers(
credential?.id
)
const isSamplePreview = PREVIEW_CONNECTED_WITH_SAMPLES && credentialId.startsWith('sample-')
const members = isSamplePreview ? getSampleConnectedMembers(credentialId) : realMembers
const membersLoading = isSamplePreview ? false : realMembersLoading
const oauthServiceNameByProviderId = useMemo(
() => new Map(oauthConnections.map((service) => [service.providerId, service.name])),
@@ -1,104 +0,0 @@
import type {
WorkspaceCredential,
WorkspaceCredentialMember,
} from '@/lib/api/contracts/credentials'
/**
* Flip to `true` to preview the Connected section on the workspace
* integrations page with the sample data below. Leave `false` for shipping.
*/
export const PREVIEW_CONNECTED_WITH_SAMPLES = true
/**
* Realistic `WorkspaceCredential` rows used to preview the Connected section
* without requiring real OAuth connections. Activated via the
* `PREVIEW_CONNECTED_WITH_SAMPLES` flag above.
*
* Each entry exercises a distinct rendering path:
* - Slack: description present → renders as-is
* - Gmail: description `null` → falls back to "Gmail integration"
* - Jira: description `""` → falls back to "Jira integration"
* - Notion: description present, role `member`
*/
export function getSampleConnectedCredentials(
workspaceId: string,
userId: string
): WorkspaceCredential[] {
const now = new Date().toISOString()
const base = {
workspaceId,
envKey: null,
envOwnerUserId: null,
createdBy: userId,
createdAt: now,
updatedAt: now,
status: 'active' as const,
}
return [
{
...base,
id: 'sample-slack',
type: 'oauth',
displayName: "Emir's Slack",
description: 'Workspace alerts and on-call routing',
providerId: 'slack',
accountId: 'sample-slack-account',
role: 'admin',
},
{
...base,
id: 'sample-gmail',
type: 'oauth',
displayName: 'Personal Gmail',
description: null,
providerId: 'google-email',
accountId: 'sample-gmail-account',
role: 'admin',
},
{
...base,
id: 'sample-jira',
type: 'oauth',
displayName: 'Engineering Jira',
description: '',
providerId: 'jira',
accountId: 'sample-jira-account',
role: 'member',
},
{
...base,
id: 'sample-notion',
type: 'oauth',
displayName: 'Team Notion',
description: 'Docs, runbooks, and meeting notes',
providerId: 'notion',
accountId: 'sample-notion-account',
role: 'member',
},
]
}
/**
* Returns sample members for a given sample credential id. Used by the
* credential-detail preview to demonstrate the Members section without a
* real workspace. Returns `[]` for any non-sample id.
*/
export function getSampleConnectedMembers(credentialId: string): WorkspaceCredentialMember[] {
if (credentialId !== 'sample-slack') return []
const now = new Date().toISOString()
return [
{
id: 'sample-slack-member-1',
userId: 'sample-user-emir',
role: 'admin',
status: 'active',
joinedAt: now,
invitedBy: null,
createdAt: now,
updatedAt: now,
userName: 'Emir Karabeg',
userEmail: 'emir@sim.ai',
userImage: null,
},
]
}
@@ -13,7 +13,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import {
blockTypeToIconMap,
formatIntegrationType,
@@ -25,10 +24,6 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
import {
getSampleConnectedCredentials,
PREVIEW_CONNECTED_WITH_SAMPLES,
} from '@/app/workspace/[workspaceId]/integrations/fixtures/sample-credentials'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
const ALL_CATEGORY = 'All'
@@ -135,19 +130,15 @@ export function Integrations() {
const [searchTerm, setSearchTerm] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string>(ALL_CATEGORY)
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})
const oauthCredentials = useMemo(() => {
const real = credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account')
if (!PREVIEW_CONNECTED_WITH_SAMPLES) return real
return [...real, ...getSampleConnectedCredentials(workspaceId, currentUserId)]
}, [credentials, workspaceId, currentUserId])
const oauthCredentials = useMemo(
() => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
[credentials]
)
const connectedItems = useMemo<ConnectedDisplayItem[]>(() => {
return oauthCredentials.flatMap((credential) => {
@@ -24,8 +24,12 @@ import {
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
type OAuthProvider,
} from '@/lib/oauth'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
@@ -515,18 +519,22 @@ export function AddConnectorModal({
connectorConfig &&
connectorConfig.auth.mode === 'oauth' &&
connectorProviderId && (
<OAuthModal
<ConnectOAuthModal
mode='connect'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()
setShowOAuthModal(false)
origin='kb-connectors'
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
provider={connectorProviderId}
serviceId={connectorConfig.auth.provider}
providerId={connectorProviderId}
requiredScopes={getCanonicalScopesForProvider(connectorProviderId)}
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
credentialCount={credentials.length}
connectorType={selectedType ?? undefined}
/>
)}
@@ -31,13 +31,9 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
type OAuthProvider,
} from '@/lib/oauth'
import { getCanonicalScopesForProvider, getProviderIdFromServiceId } 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 { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors'
@@ -598,34 +594,39 @@ function ConnectorCard({
)}
{showOAuthModal && serviceId && providerId && !connector.credentialId && (
<OAuthModal
<ConnectOAuthModal
mode='connect'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()
setShowOAuthModal(false)
origin='kb-connectors'
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
provider={providerId as OAuthProvider}
serviceId={serviceId}
providerId={providerId}
requiredScopes={getCanonicalScopesForProvider(providerId)}
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
credentialCount={credentials?.length ?? 0}
/>
)}
{showOAuthModal && serviceId && providerId && connector.credentialId && (
<OAuthModal
<ConnectOAuthModal
mode='reauthorize'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()
setShowOAuthModal(false)
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
provider={providerId as OAuthProvider}
toolName={connectorDef?.name ?? connector.connectorType}
requiredScopes={getCanonicalScopesForProvider(providerId)}
newScopes={missingScopes}
serviceId={serviceId}
providerId={providerId}
/>
)}
</div>
@@ -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
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
</div>
</div>
</div>
<WorkspaceChrome>{children}</WorkspaceChrome>
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
@@ -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 <Home key={taskId} chatId={taskId} />
export default async function TaskPage({ params, searchParams }: TaskPageProps) {
const [{ taskId }, { resource }, session] = await Promise.all([
params,
searchParams,
getSession(),
])
return (
<Home
key={taskId}
chatId={taskId}
userName={session?.user?.name}
userId={session?.user?.id}
initialResourceId={resource ?? null}
/>
)
}
@@ -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 (
<ChipSwitch<Period>
aria-label='Billing period'
value={isAnnual ? 'annual' : 'monthly'}
onChange={(next) => onChange(next === 'annual')}
className={className}
options={[
{
value: 'annual',
label: (
<>
Annual
<ChipTag variant='blue'>{DISCOUNT_LABEL}</ChipTag>
</>
),
},
{ value: 'monthly', label: 'Monthly' },
]}
/>
)
}
@@ -0,0 +1 @@
export { BillingPeriodToggle, type BillingPeriodToggleProps } from './billing-period-toggle'
@@ -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 (
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-1'>
<Label htmlFor='usage-notifications'>Usage notifications</Label>
<span className='text-[var(--text-muted)] text-small'>Email me when I reach 80% usage</span>
</div>
<Switch
id='usage-notifications'
checked={!!enabled}
disabled={isLoading}
onCheckedChange={(v: boolean) => {
if (v !== enabled) {
updateSetting.mutate({ key: 'billingUsageNotificationsEnabled', value: v })
}
}}
/>
</div>
)
}
@@ -0,0 +1 @@
export { BillingUsageNotificationsToggle } from './billing-usage-notifications-toggle'
@@ -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<string | null>(null)
const [requestId, setRequestId] = useState<string | null>(null)
const purchaseCredits = usePurchaseCredits()
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label>Additional Credits Balance:</Label>
<span className='text-[var(--text-secondary)] text-small tabular-nums'>
{isLoading ? '...' : `${formatCredits(balance)} credits`}
</span>
</div>
{canPurchase && (
<Modal open={isOpen} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>
<Button variant='active'>Add Credits</Button>
</ModalTrigger>
<ModalContent size='sm'>
<ModalHeader>Add Credits</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Purchase additional credits by entering a dollar amount.
</ModalDescription>
{purchaseCredits.isSuccess ? (
<p className='text-center text-[var(--text-primary)] text-small'>
Credits added successfully!
</p>
) : (
<div className='space-y-3'>
<div className='flex flex-col gap-2'>
<Label htmlFor='credit-amount'>Amount (USD)</Label>
<div className='relative'>
<span className='-translate-y-1/2 absolute top-1/2 left-[12px] text-[var(--text-muted)] text-small'>
$
</span>
<Input
id='credit-amount'
type='text'
inputMode='numeric'
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder='50'
className='pl-7'
disabled={purchaseCredits.isPending}
/>
</div>
{dollarAmount > 0 && !displayError && (
<span className='text-[var(--text-secondary)] text-caption tabular-nums'>
You'll receive {creditPreview.toLocaleString()} credits
</span>
)}
{displayError && (
<span className='text-[var(--text-error)] text-small'>{displayError}</span>
)}
</div>
<div className='rounded-md bg-[var(--surface-4)] p-3'>
<p className='text-[var(--text-secondary)]'>
Credits are non-refundable and don't expire. They'll be applied automatically
to your {entityType === 'organization' ? 'team' : ''} usage.
</p>
</div>
</div>
)}
</ModalBody>
{!purchaseCredits.isSuccess && (
<ModalFooter>
<ModalClose asChild>
<Button variant='default' disabled={purchaseCredits.isPending}>
Cancel
</Button>
</ModalClose>
<Button
variant='primary'
onClick={handlePurchase}
disabled={purchaseCredits.isPending || !amount}
>
{purchaseCredits.isPending ? 'Processing...' : 'Purchase'}
</Button>
</ModalFooter>
)}
</ModalContent>
</Modal>
)}
</div>
)
}
@@ -0,0 +1 @@
export { CreditBalance, type CreditBalanceProps } from './credit-balance'
@@ -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'
@@ -0,0 +1,6 @@
export {
type BilledAccountAdmin,
type ManagePlanBillingDetails,
ManagePlanModal,
type ManagePlanModalProps,
} from './manage-plan-modal'
@@ -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<void>
}
/**
* 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<void>
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<string | null>(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 (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size={modalSize}>
<ModalHeader>
Manage {currentTier.name} Plan{isTeamPlan ? ' (Team)' : ''}
</ModalHeader>
<ModalBody>
<ModalDescription className='text-[var(--text-secondary)]'>
You're on the{' '}
<span className='font-medium text-[var(--text-primary)]'>{currentTier.name}</span> plan
{isTeamPlan ? ' for your team' : ''}, billed{' '}
{currentInterval === 'month'
? `$${currentPlanDollars}/mo${perUnit}`
: `$${actualAnnualTotal}/yr${perUnit} ($${actualDiscountedMonthly}/mo${perUnit})`}
.
</ModalDescription>
{isLegacyPlan && (
<Badge variant='amber' size='lg' dot className='mt-2'>
You're on an older version of this plan
</Badge>
)}
<div className='mt-4 flex flex-col'>
{actions.map((action, i) => (
<div
key={action.title}
className={cn(
'flex items-center justify-between py-3',
i > 0 && 'border-[var(--border-1)] border-t'
)}
>
<div className='min-w-0 flex-1'>
<span className='font-medium text-[var(--text-primary)] text-small'>
{action.title}
</span>
<span className='block text-[var(--text-secondary)] text-caption'>
{action.description}
</span>
</div>
<Button
variant='primary'
className='ml-3 shrink-0'
onClick={action.onClick}
disabled={action.disabled}
>
{action.buttonText}
</Button>
</div>
))}
</div>
{error && (
<span className='mt-1 block text-[var(--text-error)] text-caption'>{error}</span>
)}
{hasBillingDetails && billingDetails && (
<div className='mt-6 flex flex-col gap-5 border-[var(--border-1)] border-t pt-5'>
<span className='font-medium text-[var(--text-primary)] text-small'>
Billing details
</span>
{billingDetails.usageHeader}
{billingDetails.creditBalance}
{billingDetails.periodEnd && (
<div className='flex items-center justify-between gap-4'>
<Label>
{billingDetails.isCancelledAtPeriodEnd ? 'Access Until' : 'Next Billing Date'}
</Label>
<span className='text-[var(--text-secondary)] text-small'>
{new Date(billingDetails.periodEnd).toLocaleDateString()}
</span>
</div>
)}
{billingDetails.notificationsToggle}
{billingDetails.showStripeActions && (
<>
<div className='flex items-center justify-between gap-4'>
<Label>Payment method</Label>
<Button
variant='active'
disabled={billingDetails.isBillingPortalPending}
onClick={() => billingDetails.onOpenBillingPortal?.()}
>
Manage in Stripe
</Button>
</div>
<div className='flex items-center justify-between gap-4'>
<Label>Invoices</Label>
<Button
variant='active'
disabled={billingDetails.isBillingPortalPending}
onClick={() => billingDetails.onOpenBillingPortal?.()}
>
View Invoices
</Button>
</div>
</>
)}
{billingDetails.showBilledAccount && (
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-1.5'>
<Label htmlFor='billed-account'>Billed Account</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='size-[12px] text-[var(--text-secondary)]' />
</Tooltip.Trigger>
<Tooltip.Content>
<span>Usage from this workspace will be billed to this account</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
{(billingDetails.workspaceAdmins ?? []).length === 0 ? (
<div className='rounded-md border border-[var(--border)] border-dashed px-3 py-1.5 text-[var(--text-muted)] text-small'>
No admins available
</div>
) : (
<div className='w-[200px]'>
<Combobox
size='sm'
align='end'
dropdownWidth={200}
value={billingDetails.billedAccountUserId || ''}
onChange={async (value: string) => {
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,
}))}
/>
</div>
)}
</div>
)}
</div>
)}
</ModalBody>
<ModalFooter>
{isCancelledAtPeriodEnd ? (
<>
<Button variant='default' onClick={() => onOpenChange(false)}>
Close
</Button>
<Button variant='primary' onClick={onRestore}>
Restore Subscription
</Button>
</>
) : (
<>
<Button variant='destructive' onClick={onCancel}>
Cancel subscription
</Button>
<Button variant='default' onClick={() => onOpenChange(false)}>
Keep Subscription
</Button>
</>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
}
@@ -0,0 +1 @@
export { UpgradePlanCard, type UpgradePlanCardProps } from './plan-card'
@@ -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 `<li>` parent.
*/
function CheckIcon() {
return (
<svg width='14' height='14' viewBox='0 0 14 14' fill='none' aria-hidden='true'>
<path
d='M2.5 7L5.5 10L11.5 4'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
/**
* 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 (
<article
className={cn(
'flex h-full flex-col gap-4 rounded-xl border border-[var(--border-1)] bg-[var(--surface-2)] p-5',
className
)}
>
<div className='flex flex-col gap-4'>
<div className='flex items-start justify-between gap-2'>
<h3 className='font-medium text-[24px] text-[var(--text-primary)]'>{name}</h3>
{showPill && <ChipTag variant='gray'>{bannerText}</ChipTag>}
</div>
<div className='flex flex-col'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[20px] text-[var(--text-primary)] tabular-nums'>
{price}
</span>
{discountLabel && <ChipTag variant='blue'>{discountLabel}</ChipTag>}
{strikethroughPrice && (
<span className='text-[var(--text-muted)] text-small line-through'>
{strikethroughPrice}
</span>
)}
</div>
<p className='text-[var(--text-muted)] text-base'>{priceSubtext ?? '\u00A0'}</p>
</div>
<button
type='button'
onClick={onButtonClick}
disabled={buttonDisabled}
className={cn(
chipVariants({
variant: highlighted ? 'primary' : 'border-shadow',
fullWidth: true,
flush: true,
}),
'w-full justify-center text-center'
)}
>
{buttonText}
</button>
</div>
<div className='flex flex-col gap-2'>
<p className='text-[var(--text-muted)] text-base'>{segmentLabel}</p>
<ul className='flex flex-col gap-2'>
{features.map((feature) => (
<li key={feature} className='flex items-center gap-2 text-[var(--text-primary)]'>
<CheckIcon />
<span className='text-[var(--text-primary)] text-small'>{feature}</span>
</li>
))}
</ul>
</div>
</article>
)
}
@@ -0,0 +1,5 @@
export {
type UpgradeState,
type UpgradeStateWorkspaceAdmin,
useUpgradeState,
} from './use-upgrade-state'
@@ -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<typeof useOrganizationBilling>['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<void>
isUpdatingWorkspace: boolean
isBillingPortalPending: boolean
managePlanModalOpen: boolean
setManagePlanModalOpen: (open: boolean) => void
usageLimitRef: RefObject<UsageLimitRef | null>
doUpgrade: (targetPlan: 'pro' | 'team', creditTier: number, seats?: number) => Promise<void>
handleSwitchInterval: (interval: 'month' | 'year') => Promise<void>
handleToggleOnDemand: () => Promise<void>
handleBadgeClick: () => Promise<void>
onCancel: () => Promise<void>
onRestore: () => Promise<void>
openBillingPortalWindow: () => void
upgradeOrSwitchToMax: () => Promise<void>
onUpgradeToOtherTier: () => Promise<void>
onUpgradeToCurrentTier: () => Promise<void>
refetchSubscription: () => Promise<unknown>
}
/**
* 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<UsageLimitRef | null>(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,
}
}
@@ -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 <Upgrade workspaceId={workspaceId} />
}
@@ -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<SVGProps<SVGSVGElement>>
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' },
]
@@ -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
}
@@ -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 ? (
<UsageHeader
title={getDisplayPlanName(state.subscription.plan)}
showBadge={state.showBadge}
badgeText={state.badgeConfig.text}
badgeVariant={state.badgeConfig.variant}
onBadgeClick={state.permissions.showTeamMemberView ? undefined : state.handleBadgeClick}
seatsText={
state.permissions.canManageTeam || state.subscription.isEnterprise
? `${state.subscription.seats} Seats`
: undefined
}
onDemandState={state.onDemandState}
onToggleOnDemand={
state.onDemandState === 'enable'
? state.handleToggleOnDemand
: state.onDemandState === 'disable' && state.canDisableOnDemand
? state.handleToggleOnDemand
: undefined
}
current={usageHeaderCurrent}
limit={usageHeaderLimit}
isBlocked={state.isBlocked}
progressValue={Math.min(state.usage.percentUsed, 100)}
rightContent={
showUsageLimit ? (
<UsageLimit
ref={state.usageLimitRef}
currentLimit={
state.subscription.isOrgScoped && state.isTeamAdmin && organizationBilling
? organizationBilling.totalUsageLimit
: state.usageLimitData.currentLimit || state.usage.limit
}
currentUsage={state.usage.current}
canEdit={state.permissions.canEditUsageLimit}
minimumLimit={
state.subscription.isOrgScoped && state.isTeamAdmin && organizationBilling
? organizationBilling.minimumBillingAmount
: state.usageLimitData.minimumLimit
}
context={state.shouldUseOrganizationBillingContext ? 'organization' : 'user'}
organizationId={
state.shouldUseOrganizationBillingContext
? (state.billingOrganizationId ?? undefined)
: undefined
}
onLimitUpdated={() => {}}
/>
) : undefined
}
/>
) : (
<div className='flex items-center'>
<span className='font-medium text-[var(--text-primary)] text-base'>
{getDisplayPlanName(state.subscription.plan)}
</span>
</div>
)
const billingDetailsCreditBalance = isPaidWithUsage ? (
<CreditBalance
balance={state.creditBalance}
canPurchase={state.hasUsablePaidAccess && state.permissions.canEditUsageLimit}
entityType={state.subscription.isOrgScoped ? 'organization' : 'user'}
isLoading={state.isLoading}
onPurchaseComplete={() => state.refetchSubscription()}
/>
) : undefined
const billingDetailsNotifications = isPaidWithUsage ? (
<BillingUsageNotificationsToggle />
) : undefined
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<Chip variant='ghost' leftIcon={ArrowLeft} onClick={handleBack}>
Back
</Chip>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 pb-20 [scrollbar-gutter:stable_both-edges]'>
<div className='mx-auto flex w-full max-w-[960px] flex-col gap-7 pt-6'>
<div className='flex flex-col items-center gap-4'>
<h1 className='text-balance text-center font-season text-[30px] text-[var(--text-primary)]'>
Plans that grow with you
</h1>
{state.showUpgradePlans && (
<BillingPeriodToggle isAnnual={state.isAnnual} onChange={state.setIsAnnual} />
)}
</div>
{state.showUpgradePlans && (
<>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
<UpgradePlanCard
name='Pro'
price={`$${proPrice}`}
discountLabel={state.isAnnual ? `${discountPct}% off` : undefined}
strikethroughPrice={state.isAnnual ? `$${state.proTier.dollars}` : undefined}
priceSubtext={priceSubtext}
segmentLabel='For growing teams'
features={PRO_PLAN_FEATURES.map((f) => 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}
/>
<UpgradePlanCard
name='Max'
price={`$${maxPrice}`}
discountLabel={state.isAnnual ? `${discountPct}% off` : undefined}
strikethroughPrice={state.isAnnual ? `$${state.maxTier.dollars}` : undefined}
priceSubtext={priceSubtext}
segmentLabel='For scaling businesses'
features={MAX_PLAN_FEATURES.map((f) => 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}
/>
<UpgradePlanCard
name='Enterprise'
price='Custom'
segmentLabel='For large organizations'
features={ENTERPRISE_PLAN_FEATURES.map((f) => f.text)}
buttonText='Talk to sales'
onButtonClick={() => window.open(TYPEFORM_ENTERPRISE_URL, '_blank')}
highlighted={state.subscription.isEnterprise}
bannerText={entBanner}
/>
</div>
</>
)}
</div>
</div>
<ManagePlanModal
open={state.managePlanModalOpen}
onOpenChange={state.setManagePlanModalOpen}
currentPlanCredits={getPlanTierCredits(state.subscription.plan)}
currentPlanDollars={getPlanTierDollars(state.subscription.plan)}
currentInterval={state.currentInterval}
isTeamPlan={state.subscription.isTeam}
isCancelledAtPeriodEnd={state.isCancelledAtPeriodEnd}
isLegacyPlan={state.isLegacyPlan}
onSwitchInterval={async (interval) => {
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
}
/>
</div>
)
}
@@ -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_])`
)
@@ -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<typeof useMentionMenu>
/** Callback to update message */
setMessage: (message: string) => void
/** Callback to update selected contexts */
setSelectedContexts: React.Dispatch<React.SetStateAction<ChatContext[]>>
}
/**
@@ -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<HTMLTextAreaElement>) => {
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,
}
}
@@ -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<ChatContext, { kind: 'file' }>
type LogsContext = Extract<ChatContext, { kind: 'logs' }>
type IntegrationContext = Extract<ChatContext, { kind: 'integration' }>
type SlashCommandContext = Extract<ChatContext, { kind: 'slash_command' }>
type SkillContext = Extract<ChatContext, { kind: 'skill' }>
/**
* 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
}
@@ -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 && (
<OAuthModal
<ConnectOAuthModal
mode='connect'
isOpen={showConnectModal}
onClose={() => 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 && (
<OAuthModal
<ConnectOAuthModal
mode='reauthorize'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()
setShowOAuthModal(false)
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
provider={provider}
toolName={getProviderName(provider)}
@@ -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 && (
<OAuthModal
<ConnectOAuthModal
mode='connect'
isOpen={showConnectModal}
onClose={() => 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 && (
<OAuthModal
<ConnectOAuthModal
mode='reauthorize'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()
setShowOAuthModal(false)
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
provider={provider}
toolName={getProviderName(provider)}
@@ -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) && <Panel workspaceId={sandbox ? workspaceId : undefined} />}
{!embedded && !sandbox && oauthModal && (
<OAuthModal
<ConnectOAuthModal
mode='reauthorize'
isOpen={true}
onClose={() => {
consumeOAuthReturnContext()
setOauthModal(null)
open={true}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setOauthModal(null)
}
}}
provider={oauthModal.provider}
toolName={oauthModal.providerName}
@@ -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<T extends string = string> {
/** 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<T extends string = string> {
/** Ordered list of options to render as segments. */
options: ChipSwitchOption<T>[]
/** 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
* <ChipSwitch
* value={view}
* onChange={setView}
* options={[
* { value: 'annual', label: <>Annual<Badge>-20%</Badge></> },
* { value: 'monthly', label: 'Monthly' },
* ]}
* />
*/
export function ChipSwitch<T extends string>({
options,
value,
onChange,
'aria-label': ariaLabel,
className,
}: ChipSwitchProps<T>) {
return (
<div
role='radiogroup'
aria-label={ariaLabel}
className={cn(
'inline-flex items-center rounded-[10px] bg-[var(--surface-5)] p-[2px]',
className
)}
>
{options.map((option) => {
const Icon = option.icon
const isActive = option.value === value
return (
<button
key={option.value}
type='button'
role='radio'
aria-checked={isActive}
data-state={isActive ? 'on' : 'off'}
onClick={() => onChange(option.value)}
className={cn(
chipVariants({
variant: isActive ? 'border-shadow' : 'ghost',
flush: true,
}),
'justify-center',
isActive
? 'text-[var(--text-primary)] shadow-none hover-hover:bg-[var(--surface-2)]'
: 'text-[var(--text-muted)] hover-hover:bg-transparent hover-hover:text-[var(--text-primary)]'
)}
>
{Icon ? <Icon className='size-[14px] flex-shrink-0' /> : null}
{option.label}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,5 @@
export {
ChipSwitch,
type ChipSwitchOption,
type ChipSwitchProps,
} from './chip-switch'
@@ -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<HTMLAttributes<HTMLSpanElement>, 'children'>,
VariantProps<typeof chipTagVariants> {
/** Tag content — typically a short label, number, or percentage. */
children: ReactNode
}
/**
* A compact tinted tag in the chip language.
*
* @example
* <ChipTag variant='blue'>-20%</ChipTag>
* <ChipTag variant='green'>New</ChipTag>
*/
export function ChipTag({ variant, className, children, ...props }: ChipTagProps) {
return (
<span className={cn(chipTagVariants({ variant }), className)} {...props}>
{children}
</span>
)
}
export { chipTagVariants }
@@ -0,0 +1 @@
export { ChipTag, type ChipTagProps, chipTagVariants } from './chip-tag'
@@ -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' },
@@ -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,
+1
View File
@@ -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'
+27
View File
@@ -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<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M14 3L6.5 17.5' />
</svg>
)
}
+24
View File
@@ -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.)
+16
View File
@@ -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
+5
View File
@@ -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'
@@ -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)
}
+27
View File
@@ -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<FullscreenOriginState>()(
devtools(
(set) => ({
...initialState,
setOrigin: (origin) => set({ origin }, false, 'setOrigin'),
}),
{ name: 'fullscreen-origin-store' }
)
)
+1
View File
@@ -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 }