mirror of
https://github.com/simstudioai/sim.git
synced 2026-11-04 07:42:05 +08:00
upgrade
This commit is contained in:
+455
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
@@ -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)}
|
||||
|
||||
+42
-10
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+225
@@ -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'
|
||||
|
||||
+31
-13
@@ -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' &&
|
||||
|
||||
+214
@@ -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>
|
||||
)
|
||||
})
|
||||
)
|
||||
+188
@@ -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 {
|
||||
|
||||
+9
-58
@@ -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}
|
||||
|
||||
-273
@@ -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
@@ -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}`
|
||||
}
|
||||
|
||||
+5
-20
@@ -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) => {
|
||||
|
||||
+16
-8
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
+20
-19
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+51
@@ -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'
|
||||
+37
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { BillingUsageNotificationsToggle } from './billing-usage-notifications-toggle'
|
||||
+213
@@ -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'
|
||||
+367
@@ -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>
|
||||
)
|
||||
}
|
||||
+8
-2
@@ -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_])`
|
||||
)
|
||||
|
||||
+35
-76
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+37
-1
@@ -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
|
||||
}
|
||||
|
||||
+14
-10
@@ -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)}
|
||||
|
||||
+14
-10
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user