diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index 20a55c8e..575b1d47 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -24,10 +24,11 @@ import { SiNotion } from 'react-icons/si'; import { AgentConfigModal } from '@/components/agents/agent-config-modal'; import { PipedreamRegistry } from '@/components/agents/pipedream/pipedream-registry'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { useSubscription } from '@/hooks/react-query/subscriptions/use-subscriptions'; +import { useSubscriptionWithStreaming } from '@/hooks/react-query/subscriptions/use-subscriptions'; import { isLocalMode } from '@/lib/config'; import { Button } from '@/components/ui/button'; import { AnimatePresence } from 'framer-motion'; +import { BillingModal } from '@/components/billing/billing-modal'; export interface ChatInputHandles { getPendingFiles: () => File[]; @@ -64,6 +65,7 @@ export interface ChatInputProps { onConfigureAgent?: (agentId: string) => void; hideAgentSelection?: boolean; defaultShowSnackbar?: 'tokens' | 'upgrade' | false; + showToLowCreditUsers?: boolean; } export interface UploadedFile { @@ -105,6 +107,7 @@ export const ChatInput = forwardRef( onConfigureAgent, hideAgentSelection = false, defaultShowSnackbar = false, + showToLowCreditUsers = true, }, ref, ) => { @@ -122,6 +125,7 @@ export const ChatInput = forwardRef( const [configModalTab, setConfigModalTab] = useState('integrations'); const [registryDialogOpen, setRegistryDialogOpen] = useState(false); const [showSnackbar, setShowSnackbar] = useState(defaultShowSnackbar); + const [billingModalOpen, setBillingModalOpen] = useState(false); const { selectedModel, @@ -133,19 +137,34 @@ export const ChatInput = forwardRef( refreshCustomModels, } = useModelSelection(); - const { data: subscriptionData } = useSubscription(); + const { data: subscriptionData } = useSubscriptionWithStreaming(isAgentRunning); const deleteFileMutation = useFileDelete(); const queryClient = useQueryClient(); - // Simple logic: show usage preview if we have subscription data and not in local mode - const shouldShowUsage = !isLocalMode() && subscriptionData; + // Show usage preview logic: + // - Always show to free users when showToLowCreditUsers is true + // - For paid users, only show when they're at 70% or more of their cost limit (30% or below remaining) + const shouldShowUsage = !isLocalMode() && subscriptionData && showToLowCreditUsers && (() => { + // Free users: always show + if (subscriptionStatus === 'no_subscription') { + return true; + } + + // Paid users: only show when at 70% or more of cost limit + const currentUsage = subscriptionData.current_usage || 0; + const costLimit = subscriptionData.cost_limit || 0; + + if (costLimit === 0) return false; // No limit set + + return currentUsage >= (costLimit * 0.7); // 70% or more used (30% or less remaining) + })(); // Auto-show usage preview when we have subscription data useEffect(() => { if (shouldShowUsage && defaultShowSnackbar !== false && (showSnackbar === false || showSnackbar === defaultShowSnackbar)) { setShowSnackbar('upgrade'); } - }, [subscriptionData, showSnackbar, defaultShowSnackbar, shouldShowUsage]); + }, [subscriptionData, showSnackbar, defaultShowSnackbar, shouldShowUsage, subscriptionStatus, showToLowCreditUsers]); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -320,6 +339,7 @@ export const ChatInput = forwardRef( showUsagePreview={showSnackbar} subscriptionData={subscriptionData} onCloseUsage={() => setShowSnackbar(false)} + onOpenUpgrade={() => setBillingModalOpen(true)} isVisible={showToolPreview || !!showSnackbar} /> ( /> + ); diff --git a/frontend/src/components/thread/chat-input/chat-snack.tsx b/frontend/src/components/thread/chat-input/chat-snack.tsx index c52616b5..e73ce1b3 100644 --- a/frontend/src/components/thread/chat-input/chat-snack.tsx +++ b/frontend/src/components/thread/chat-input/chat-snack.tsx @@ -4,6 +4,7 @@ import { X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { UsagePreview } from './usage-preview'; import { FloatingToolPreview, ToolCallInput } from './floating-tool-preview'; +import { isLocalMode } from '@/lib/config'; export interface ChatSnackProps { // Tool preview props @@ -17,6 +18,7 @@ export interface ChatSnackProps { showUsagePreview?: 'tokens' | 'upgrade' | false; subscriptionData?: any; onCloseUsage?: () => void; + onOpenUpgrade?: () => void; // General props isVisible?: boolean; @@ -34,16 +36,21 @@ export const ChatSnack: React.FC = ({ showUsagePreview = false, subscriptionData, onCloseUsage, + onOpenUpgrade, isVisible = false, }) => { const [currentView, setCurrentView] = React.useState(0); - // Determine what notifications we have + // Determine what notifications we have - match exact rendering conditions const notifications = []; + + // Tool notification: only if we have tool calls and showToolPreview is true if (showToolPreview && toolCalls.length > 0) { notifications.push('tool'); } - if (showUsagePreview) { + + // Usage notification: must match ALL rendering conditions + if (showUsagePreview && !isLocalMode() && subscriptionData) { notifications.push('usage'); } @@ -89,7 +96,7 @@ export const ChatSnack: React.FC = ({ ); } - if (currentNotification === 'usage' && showUsagePreview) { + if (currentNotification === 'usage' && showUsagePreview && !isLocalMode()) { return ( = ({ > { + // Don't trigger if clicking on indicators or close button + const target = e.target as HTMLElement; + const isIndicatorClick = target.closest('[data-indicator-click]'); + const isCloseClick = target.closest('[data-close-click]'); + + if (!isIndicatorClick && !isCloseClick && onOpenUpgrade) { + onOpenUpgrade(); + } + }} > = ({ currentIndex={currentView} totalCount={totalNotifications} onIndicatorClick={(index) => setCurrentView(index)} + onOpenUpgrade={onOpenUpgrade} /> diff --git a/frontend/src/components/thread/chat-input/message-input.tsx b/frontend/src/components/thread/chat-input/message-input.tsx index 2ffff07d..8101e6d7 100644 --- a/frontend/src/components/thread/chat-input/message-input.tsx +++ b/frontend/src/components/thread/chat-input/message-input.tsx @@ -277,13 +277,13 @@ export const MessageInput = forwardRef( - {subscriptionStatus === 'no_subscription' && !isLocalMode() && + {/* {subscriptionStatus === 'no_subscription' && !isLocalMode() &&

Upgrade for better performance

- } + } */} ); }, diff --git a/frontend/src/components/thread/chat-input/usage-preview.tsx b/frontend/src/components/thread/chat-input/usage-preview.tsx index 1a959a1f..ddf5e300 100644 --- a/frontend/src/components/thread/chat-input/usage-preview.tsx +++ b/frontend/src/components/thread/chat-input/usage-preview.tsx @@ -9,6 +9,7 @@ export interface UsagePreviewProps { type: 'tokens' | 'upgrade'; subscriptionData?: any; onClose?: () => void; + onOpenUpgrade?: () => void; hasMultiple?: boolean; showIndicators?: boolean; currentIndex?: number; @@ -20,6 +21,7 @@ export const UsagePreview: React.FC = ({ type, subscriptionData, onClose, + onOpenUpgrade, hasMultiple = false, showIndicators = false, currentIndex = 0, @@ -100,6 +102,7 @@ export const UsagePreview: React.FC = ({ {/* Apple-style notification indicators - only for multiple notification types */} {showIndicators && totalCount === 2 && ( )} - diff --git a/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts b/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts index b0b394f4..0711e3fb 100644 --- a/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts +++ b/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts @@ -7,6 +7,8 @@ import { SubscriptionStatus, } from '@/lib/api'; import { subscriptionKeys } from './keys'; +import { useQuery } from '@tanstack/react-query'; +import { useState, useEffect } from 'react'; export const useSubscription = createQueryHook( subscriptionKeys.details(), @@ -17,6 +19,39 @@ export const useSubscription = createQueryHook( }, ); +// Smart subscription hook that adapts refresh based on streaming state +export const useSubscriptionWithStreaming = (isStreaming: boolean = false) => { + const [isVisible, setIsVisible] = useState(true); + + // Track page visibility + useEffect(() => { + const handleVisibilityChange = () => { + setIsVisible(!document.hidden); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, []); + + return useQuery({ + queryKey: subscriptionKeys.details(), + queryFn: getSubscription, + staleTime: 1000 * 60 * 2, // 2 minutes + refetchOnWindowFocus: true, + refetchInterval: (data) => { + // No refresh if tab is hidden + if (!isVisible) return false; + + // If actively streaming: refresh every 5s (costs are changing) + if (isStreaming) return 5 * 1000; + + // If visible but not streaming: refresh every 5min + return 5 * 60 * 1000; + }, + refetchIntervalInBackground: false, // Stop when tab backgrounded + }); +}; + export const useCreatePortalSession = createMutationHook( (params: { return_url: string }) => createPortalSession(params), {