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' }
+ )
+)