diff --git a/apps/sim/app/extension/[workspaceId]/workspace.tsx b/apps/sim/app/extension/[workspaceId]/workspace.tsx index 30c3011d48d..e0b5b393dff 100644 --- a/apps/sim/app/extension/[workspaceId]/workspace.tsx +++ b/apps/sim/app/extension/[workspaceId]/workspace.tsx @@ -5,6 +5,7 @@ import { isApiClientError } from '@/lib/api/client/errors' import { ExtensionChat } from '@/app/extension/[workspaceId]/chat' import ExtensionLoading from '@/app/extension/loading' import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal' import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' @@ -65,6 +66,7 @@ export function ExtensionWorkspace({ workspaceId }: ExtensionWorkspaceProps) { > + diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index d168f05f423..5762d963c63 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -16,6 +16,7 @@ import { usePathname } from 'next/navigation' import { getDesktopBridge } from '@/lib/desktop' import { SidebarChromeProvider } from '@/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context' import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek' +import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { SIDEBAR_NO_MOTION_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { isMobileViewport, useIsMobile } from '@/hooks/use-is-mobile' @@ -446,6 +447,7 @@ export function WorkspaceChrome({ )} {!isFullscreen && } + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/chat-plan-modal.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/chat-plan-modal.tsx new file mode 100644 index 00000000000..d39202f73f5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/chat-plan-modal.tsx @@ -0,0 +1,75 @@ +'use client' + +import { + ChipModal, + ChipModalBody, + ChipModalDescription, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { useRouter } from 'next/navigation' +import { CHAT_PLAN_REQUIRED_UPGRADE } from '@/lib/billing/chat-plan' +import { rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' +import { usePlanUpgradeLink } from '@/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link' +import { useChatPlanModalStore } from '@/stores/modals/chat-plan/store' + +/** + * Answers a send the workspace's plan refuses: Chat is on paid plans, with the way to upgrade + * for a viewer who may, and who to ask for one who may not. Render it once per workspace shell. + */ +export function ChatPlanModal() { + const isOpen = useChatPlanModalStore((state) => state.isOpen) + const close = useChatPlanModalStore((state) => state.close) + + return ( + !open && close()} + srTitle='Paid Plan Required' + size='sm' + > + + + ) +} + +interface ChatPlanModalContentProps { + onClose: () => void +} + +/** Mounted only while the modal shows, so a closed modal reads no session or billing context. */ +function ChatPlanModalContent({ onClose }: ChatPlanModalContentProps) { + const router = useRouter() + const { href, opensInNewTab, buttonLabel, canManageBilling, unavailableMessage } = + usePlanUpgradeLink(CHAT_PLAN_REQUIRED_UPGRADE) + + const upgrade = () => { + onClose() + if (opensInNewTab) { + window.open(href, '_blank', 'noopener,noreferrer') + return + } + rememberSettingsReturnUrl(href) + router.push(href) + } + + return ( + <> + Paid Plan Required + + + {CHAT_PLAN_REQUIRED_UPGRADE.message} + {!canManageBilling && ` ${unavailableMessage}`} + + + {canManageBilling ? ( + + ) : ( + + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/index.ts new file mode 100644 index 00000000000..ff1589cb0f7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/index.ts @@ -0,0 +1 @@ +export { ChatPlanModal } from './chat-plan-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/chat-plan-notice.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/chat-plan-notice.tsx deleted file mode 100644 index 7bad390b5fa..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/chat-plan-notice.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { CHAT_PLAN_REQUIRED_UPGRADE } from '@/lib/billing/chat-plan' -import { PlanUsageLimitDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' -import { useChatPlanRequired } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-required' - -/** - * The upgrade card above the composer while the workspace's plan does not include Chat, the same - * card run admission writes, so a free payer learns why Chat is held before sending. Render it - * beside every `UserInput`, which disables send on the same verdict. - */ -export function ChatPlanNotice() { - const chatPlanRequired = useChatPlanRequired() - if (!chatPlanRequired) return null - - return ( -
- -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/index.ts deleted file mode 100644 index 60edaf2cd0a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-notice/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChatPlanNotice } from './chat-plan-notice' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts index 2d4cbf2290c..fb354716ea3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts @@ -1,5 +1,4 @@ export { ChatMessageAttachments } from './chat-message-attachments' -export { ChatPlanNotice } from './chat-plan-notice' export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context' export { ContextMentionIcon } from './context-mention-icon' export { CreditsChip } from './credits-chip' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index 99084915309..2f43b29539e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -27,7 +27,6 @@ export { credentialTagHasVisibleCard, formatCredentialSubmissionMessage, isHttpUrl, - PlanUsageLimitDisplay, parseCredentialSubmissionMessage, parseCredentialSubmissionProgress, parseCredentialTagBody, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index fa60fa4238d..a082d663828 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -30,18 +30,9 @@ import { } from '@sim/emcn/icons' import { isRecordLike, omit } from '@sim/utils/object' import { useParams } from 'next/navigation' -import { getOrganizationSettingsHref } from '@/components/settings/navigation' -import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider' import { useSession } from '@/lib/auth/client' import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan' -import { - buildHostedUpgradeUrl, - buildUpgradeHref, - HOSTED_BILLING_SETTINGS_URL, -} from '@/lib/billing/upgrade-reasons' -import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { readLatestOAuthChatAttempt } from '@/lib/credentials/oauth-chat-attempt' import { getDesktopBridge } from '@/lib/desktop' @@ -64,7 +55,6 @@ import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { organizationSecretNameSchema } from '@/lib/organization-secrets/validation' import { finishTerminalHandoff, isTerminalAvailable } from '@/lib/terminal/transport' -import { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { @@ -93,6 +83,7 @@ import { } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection' import { usePersonalCredentialConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-personal-credential-connection' import { ResourceWorkspaceHost } from '@/app/workspace/[workspaceId]/home/components/resource-workspace-host' +import { usePlanUpgradeLink } from '@/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link' import type { ChatMessageContext, MothershipResource, @@ -121,7 +112,6 @@ import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileAtPath, useWorkspaceFileLookup } from '@/hooks/queries/workspace-files' import { useWorkspaceUsageGate } from '@/hooks/queries/workspace-usage' -import { useSettingsNavigation } from '@/hooks/use-settings-navigation' export interface OptionsItemData { title: string @@ -3500,45 +3490,14 @@ interface PlanUsageLimitDisplayProps { * The payer's plan limit, or a free plan that does not include Chat: the card links to where * the plan or the limit can change, and a workspace's plan to its upgrade page. */ -export function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) { - const { data: session } = useSession() +function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) { const hostContext = useOptionalWorkspaceHostContext() - const organizationContext = useOptionalOrganizationContext() - const { getSettingsHref } = useSettingsNavigation() - const { hosted } = useDeploymentShape() - const navigationTarget = useSettingsNavigationTarget() - const opensInNewTab = !hosted || navigationTarget === '_blank' + const { href, opensInNewTab, buttonLabel, canManageBilling, unavailableMessage } = + usePlanUpgradeLink(data) const planRequired = data.reason === CHAT_PLAN_REQUIRED_REASON - const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' - - // Self-hosted plan and limit both live on the hosted account, so local - // workspace billing roles say nothing about who may change them. - const href = hosted - ? organizationContext - ? getOrganizationSettingsHref(organizationContext.organization.id, 'billing') - : planRequired && hostContext - ? buildUpgradeHref(hostContext.workspace.id) - : getSettingsHref({ section: 'billing' }) - : data.action === 'upgrade_plan' - ? buildHostedUpgradeUrl() - : HOSTED_BILLING_SETTINGS_URL - const canManageBilling = - !hosted || - (organizationContext - ? organizationContext.viewer.isAdmin - : Boolean(hostContext && canManageWorkspaceBilling(hostContext, session?.user?.id))) const usageGate = useWorkspaceUsageGate( data.action === 'increase_limit' && !canManageBilling ? hostContext?.workspace.id : undefined ) - const action = planRequired - ? 'upgrade this workspace’s plan' - : 'manage this workspace’s usage limits' - const unavailableMessage = - organizationContext && !planRequired - ? 'Contact an organization admin to manage usage limits.' - : organizationContext || hostContext?.hostOrganizationId - ? `Contact an organization admin to ${action}.` - : `Only the workspace owner can ${action}.` return (
- {!isLoading && } (function UserI const canSubmit = (editor.value.trim().length > 0 || hasFiles || hasAdditionalAttachments) && !isSending && - !hasUploadingFiles && - !chatPlanRequired + !hasUploadingFiles /** * Sync the editor when the `defaultValue` prop changes post-mount — e.g. @@ -532,7 +532,10 @@ const UserInputImpl = forwardRef(function UserI }, [resetTranscript, history.reset]) const handleSubmit = useCallback(() => { - if (chatPlanRequired) return + if (chatPlanRequired) { + useChatPlanModalStore.getState().open() + return + } const currentFiles = filesRef.current const currentEditor = editorRef.current diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 724943dfac0..1e3523389f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -41,7 +41,6 @@ import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { - ChatPlanNotice, ChatSurfaceProvider, CreditsChip, MothershipChat, @@ -443,7 +442,6 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { What should we get done{firstName ? `, ${firstName}` : ''}?
- { if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return - if (chatPlanRequired) return + if (chatPlanRequired) { + useChatPlanModalStore.getState().open() + return + } const queueStore = useMothershipQueueStore.getState() const activeChatKey = chatKeyRef.current @@ -5302,7 +5306,10 @@ export function useChat( const msg = id === undefined ? queue?.[0] : queue?.find((queued) => queued.id === id) if (!msg || queueState.editing[chatKey] === msg.id) return if (queuedMessageDispatchIds.has(msg.id)) return - if (chatPlanRequired) return + if (chatPlanRequired) { + useChatPlanModalStore.getState().open() + return + } /* Sent by hand, it still must not run a second turn: if the history shows it accepted, it is already in the chat. A history that cannot be read does not hold it back here; the user asked to send it, and the server deduplicates it diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link.ts new file mode 100644 index 00000000000..8bedaaa81a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link.ts @@ -0,0 +1,80 @@ +'use client' + +import { getOrganizationSettingsHref } from '@/components/settings/navigation' +import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider' +import { useSession } from '@/lib/auth/client' +import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan' +import { + buildHostedUpgradeUrl, + buildUpgradeHref, + HOSTED_BILLING_SETTINGS_URL, +} from '@/lib/billing/upgrade-reasons' +import type { UsageUpgradeTagPayload } from '@/lib/billing/usage-upgrade' +import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' + +interface PlanUpgradeLinkInput { + reason: string + action: UsageUpgradeTagPayload['action'] +} + +interface PlanUpgradeLink { + href: string + opensInNewTab: boolean + buttonLabel: string + /** Whether the viewer may change the plan or limit; when false, show `unavailableMessage`. */ + canManageBilling: boolean + unavailableMessage: string +} + +/** + * Where a usage-upgrade prompt sends the viewer, and whether they may go: the payer's plan or + * limit, or a free plan that does not include Chat. A workspace's plan links to its upgrade page; + * self-hosted plans and limits live on the hosted account. + */ +export function usePlanUpgradeLink(data: PlanUpgradeLinkInput): PlanUpgradeLink { + const { data: session } = useSession() + const hostContext = useOptionalWorkspaceHostContext() + const organizationContext = useOptionalOrganizationContext() + const { getSettingsHref } = useSettingsNavigation() + const { hosted } = useDeploymentShape() + const navigationTarget = useSettingsNavigationTarget() + const planRequired = data.reason === CHAT_PLAN_REQUIRED_REASON + + // Self-hosted plan and limit both live on the hosted account, so local + // workspace billing roles say nothing about who may change them. + const href = hosted + ? organizationContext + ? getOrganizationSettingsHref(organizationContext.organization.id, 'billing') + : planRequired && hostContext + ? buildUpgradeHref(hostContext.workspace.id) + : getSettingsHref({ section: 'billing' }) + : data.action === 'upgrade_plan' + ? buildHostedUpgradeUrl() + : HOSTED_BILLING_SETTINGS_URL + const canManageBilling = + !hosted || + (organizationContext + ? organizationContext.viewer.isAdmin + : Boolean(hostContext && canManageWorkspaceBilling(hostContext, session?.user?.id))) + const action = planRequired + ? 'upgrade this workspace’s plan' + : 'manage this workspace’s usage limits' + const unavailableMessage = + organizationContext && !planRequired + ? 'Contact an organization admin to manage usage limits.' + : organizationContext || hostContext?.hostOrganizationId + ? `Contact an organization admin to ${action}.` + : `Only the workspace owner can ${action}.` + + return { + href, + opensInNewTab: !hosted || navigationTarget === '_blank', + buttonLabel: data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit', + canManageBilling, + unavailableMessage, + } +} diff --git a/apps/sim/lib/billing/chat-plan.ts b/apps/sim/lib/billing/chat-plan.ts index 1e50f9634b6..2b87add362f 100644 --- a/apps/sim/lib/billing/chat-plan.ts +++ b/apps/sim/lib/billing/chat-plan.ts @@ -4,7 +4,8 @@ export const CHAT_PLAN_REQUIRED_REASON = 'plan_required' as const /** * The card for a payer whose plan does not include Chat. Sim writes it at run admission, before * any worker run exists, so it never crosses the worker billing protocol: its reason widens only - * the tag Sim renders, not the worker-shared card. The composer shows the same card before a send. + * the tag Sim renders, not the worker-shared card. A send the composer refuses opens a modal with + * the same message. */ export const CHAT_PLAN_REQUIRED_UPGRADE = { reason: CHAT_PLAN_REQUIRED_REASON, diff --git a/apps/sim/stores/modals/chat-plan/store.ts b/apps/sim/stores/modals/chat-plan/store.ts new file mode 100644 index 00000000000..1008b2aa985 --- /dev/null +++ b/apps/sim/stores/modals/chat-plan/store.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface ChatPlanModalState { + isOpen: boolean + open: () => void + close: () => void +} + +/** + * Whether the Chat plan modal is showing. A send the payer's plan refuses opens it from wherever + * the send started (the composer, a queued message, a handoff from another surface), and the + * workspace shell renders the one modal. + */ +export const useChatPlanModalStore = create()( + devtools( + (set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + }), + { name: 'chat-plan-modal-store' } + ) +)