diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx index 6429c760..daf92adb 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx @@ -619,7 +619,7 @@ export default function ThreadPage({ )} */} - + diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index 0d4ad94f..575b1d47 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -7,6 +7,8 @@ import React, { forwardRef, useImperativeHandle, } from 'react'; + +import { motion } from 'framer-motion'; import { Card, CardContent } from '@/components/ui/card'; import { handleFiles } from './file-upload-handler'; import { MessageInput } from './message-input'; @@ -14,13 +16,19 @@ import { AttachmentGroup } from '../attachment-group'; import { useModelSelection } from './_use-model-selection'; import { useFileDelete } from '@/hooks/react-query/files'; import { useQueryClient } from '@tanstack/react-query'; -import { FloatingToolPreview, ToolCallInput } from './floating-tool-preview'; -import { Settings2, Sparkles, Brain, ChevronRight, Zap, Workflow, Database, Wrench } from 'lucide-react'; +import { ToolCallInput } from './floating-tool-preview'; +import { ChatSnack } from './chat-snack'; +import { Brain, ChevronRight, Zap, Workflow, Database, Wrench, X } from 'lucide-react'; import { FaGoogle, FaDiscord } from 'react-icons/fa'; 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 { 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[]; @@ -56,6 +64,8 @@ export interface ChatInputProps { enableAdvancedConfig?: boolean; onConfigureAgent?: (agentId: string) => void; hideAgentSelection?: boolean; + defaultShowSnackbar?: 'tokens' | 'upgrade' | false; + showToLowCreditUsers?: boolean; } export interface UploadedFile { @@ -66,6 +76,8 @@ export interface UploadedFile { localUrl?: string; } + + export const ChatInput = forwardRef( ( { @@ -94,6 +106,8 @@ export const ChatInput = forwardRef( enableAdvancedConfig = false, onConfigureAgent, hideAgentSelection = false, + defaultShowSnackbar = false, + showToLowCreditUsers = true, }, ref, ) => { @@ -110,6 +124,8 @@ export const ChatInput = forwardRef( const [configModalOpen, setConfigModalOpen] = useState(false); const [configModalTab, setConfigModalTab] = useState('integrations'); const [registryDialogOpen, setRegistryDialogOpen] = useState(false); + const [showSnackbar, setShowSnackbar] = useState(defaultShowSnackbar); + const [billingModalOpen, setBillingModalOpen] = useState(false); const { selectedModel, @@ -121,9 +137,35 @@ export const ChatInput = forwardRef( refreshCustomModels, } = useModelSelection(); + const { data: subscriptionData } = useSubscriptionWithStreaming(isAgentRunning); const deleteFileMutation = useFileDelete(); const queryClient = useQueryClient(); + // 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, subscriptionStatus, showToLowCreditUsers]); + const textareaRef = useRef(null); const fileInputRef = useRef(null); const hasLoadedFromLocalStorage = useRef(false); @@ -137,7 +179,7 @@ export const ChatInput = forwardRef( if (typeof window !== 'undefined' && onAgentSelect && !hasLoadedFromLocalStorage.current) { const urlParams = new URLSearchParams(window.location.search); const hasAgentIdInUrl = urlParams.has('agent_id'); - + if (!selectedAgentId && !hasAgentIdInUrl) { const savedAgentId = localStorage.getItem('lastSelectedAgentId'); if (savedAgentId) { @@ -283,185 +325,200 @@ export const ChatInput = forwardRef( setIsDraggingOver(false); }; + + return ( -
- { })} - agentName={agentName} - isVisible={showToolPreview} - /> - { - e.preventDefault(); - e.stopPropagation(); - setIsDraggingOver(false); - if (fileInputRef.current && e.dataTransfer.files.length > 0) { - const files = Array.from(e.dataTransfer.files); - handleFiles( - files, - sandboxId, - setPendingFiles, - setUploadedFiles, - setIsUploading, - messages, - queryClient, - ); - } - }} - > -
- - - +
+ setShowSnackbar(false)} + onOpenUpgrade={() => setBillingModalOpen(true)} + isVisible={showToolPreview || !!showSnackbar} + /> + { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(false); + if (fileInputRef.current && e.dataTransfer.files.length > 0) { + const files = Array.from(e.dataTransfer.files); + handleFiles( + files, + sandboxId, + setPendingFiles, + setUploadedFiles, + setIsUploading, + messages, + queryClient, + ); + } + }} + > - fileInputRef={fileInputRef} - isUploading={isUploading} - sandboxId={sandboxId} - setPendingFiles={setPendingFiles} - setUploadedFiles={setUploadedFiles} - setIsUploading={setIsUploading} - hideAttachments={hideAttachments} - messages={messages} - selectedModel={selectedModel} - onModelChange={handleModelChange} - modelOptions={modelOptions} - subscriptionStatus={subscriptionStatus} - canAccessModel={canAccessModel} - refreshCustomModels={refreshCustomModels} - isLoggedIn={isLoggedIn} +
+ + + - - - {enableAdvancedConfig && selectedAgentId && ( -
-
-
-
- Integrations - - -
- - - -
- - - -
- - - -
- - + Integrations + + +
+ + + +
+ + + +
+ + + +
+ + +
-
- )} -
- - - - - - Integrations - - { - console.log('Tools selected:', { profileId, selectedTools, appName, appSlug }); - }} - /> - - + )} +
+ + + + + + Integrations + + { + console.log('Tools selected:', { profileId, selectedTools, appName, appSlug }); + }} + /> + + + +
); }, diff --git a/frontend/src/components/thread/chat-input/chat-snack.tsx b/frontend/src/components/thread/chat-input/chat-snack.tsx new file mode 100644 index 00000000..e73ce1b3 --- /dev/null +++ b/frontend/src/components/thread/chat-input/chat-snack.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +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 + toolCalls?: ToolCallInput[]; + toolCallIndex?: number; + onExpandToolPreview?: () => void; + agentName?: string; + showToolPreview?: boolean; + + // Usage preview props + showUsagePreview?: 'tokens' | 'upgrade' | false; + subscriptionData?: any; + onCloseUsage?: () => void; + onOpenUpgrade?: () => void; + + // General props + isVisible?: boolean; +} + +const SNACK_LAYOUT_ID = 'chat-snack-float'; +const SNACK_CONTENT_LAYOUT_ID = 'chat-snack-content'; + +export const ChatSnack: React.FC = ({ + toolCalls = [], + toolCallIndex = 0, + onExpandToolPreview, + agentName, + showToolPreview = false, + showUsagePreview = false, + subscriptionData, + onCloseUsage, + onOpenUpgrade, + isVisible = false, +}) => { + const [currentView, setCurrentView] = React.useState(0); + + // 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'); + } + + // Usage notification: must match ALL rendering conditions + if (showUsagePreview && !isLocalMode() && subscriptionData) { + notifications.push('usage'); + } + + const totalNotifications = notifications.length; + const hasMultiple = totalNotifications > 1; + + // Reset currentView when notifications change + React.useEffect(() => { + if (currentView >= totalNotifications && totalNotifications > 0) { + setCurrentView(0); + } + }, [totalNotifications, currentView]); + + // Auto-cycle through notifications + React.useEffect(() => { + if (!hasMultiple || !isVisible) return; + + const interval = setInterval(() => { + setCurrentView((prev) => (prev + 1) % totalNotifications); + }, 20000); + + return () => clearInterval(interval); + }, [hasMultiple, isVisible, totalNotifications, currentView]); // Reset timer when currentView changes + + if (!isVisible || totalNotifications === 0) return null; + + const currentNotification = notifications[currentView]; + + const renderContent = () => { + if (currentNotification === 'tool' && showToolPreview) { + return ( + { })} + agentName={agentName} + isVisible={true} + showIndicators={hasMultiple} + indicatorIndex={currentView} + indicatorTotal={totalNotifications} + onIndicatorClick={(index) => setCurrentView(index)} + /> + ); + } + + 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(); + } + }} + > + { + if (onCloseUsage) onCloseUsage(); + // If there are other notifications, switch to them + if (totalNotifications > 1) { + const remainingNotifications = notifications.filter(n => n !== 'usage'); + if (remainingNotifications.length > 0) { + setCurrentView(0); // Switch to first remaining notification + } + } + }} + hasMultiple={hasMultiple} + showIndicators={hasMultiple} + currentIndex={currentView} + totalCount={totalNotifications} + onIndicatorClick={(index) => setCurrentView(index)} + onOpenUpgrade={onOpenUpgrade} + /> + + + ); + } + + return null; + }; + + return ( + + {isVisible && ( + + {renderContent()} + + )} + + ); +}; diff --git a/frontend/src/components/thread/chat-input/floating-tool-preview.tsx b/frontend/src/components/thread/chat-input/floating-tool-preview.tsx index 17bcd54b..3272b427 100644 --- a/frontend/src/components/thread/chat-input/floating-tool-preview.tsx +++ b/frontend/src/components/thread/chat-input/floating-tool-preview.tsx @@ -25,6 +25,11 @@ interface FloatingToolPreviewProps { onExpand: () => void; agentName?: string; isVisible: boolean; + // Indicators for multiple notification types (not tool calls) + showIndicators?: boolean; + indicatorIndex?: number; + indicatorTotal?: number; + onIndicatorClick?: (index: number) => void; } const FLOATING_LAYOUT_ID = 'tool-panel-float'; @@ -61,6 +66,10 @@ export const FloatingToolPreview: React.FC = ({ onExpand, agentName, isVisible, + showIndicators = false, + indicatorIndex = 0, + indicatorTotal = 1, + onIndicatorClick, }) => { const [isExpanding, setIsExpanding] = React.useState(false); const currentToolCall = toolCalls[currentIndex]; @@ -137,11 +146,6 @@ export const FloatingToolPreview: React.FC = ({

{getUserFriendlyToolName(toolName)}

-
- - {currentIndex + 1}/{totalCalls} - -
@@ -164,6 +168,32 @@ export const FloatingToolPreview: React.FC = ({
+ {/* Apple-style notification indicators - only for multiple notification types */} + {showIndicators && indicatorTotal === 2 && ( + + )} + diff --git a/frontend/src/components/thread/chat-input/message-input.tsx b/frontend/src/components/thread/chat-input/message-input.tsx index ded27ec4..8101e6d7 100644 --- a/frontend/src/components/thread/chat-input/message-input.tsx +++ b/frontend/src/components/thread/chat-input/message-input.tsx @@ -156,7 +156,7 @@ export const MessageInput = forwardRef( const renderDropdown = () => { if (isLoggedIn) { const showAdvancedFeatures = enableAdvancedConfig || (customAgentsEnabled && !flagsLoading); - + return (
{showAdvancedFeatures && !hideAgentSelection && ( @@ -223,7 +223,7 @@ export const MessageInput = forwardRef(
- {subscriptionStatus === 'no_subscription' && !isLocalMode() && + {/* {subscriptionStatus === 'no_subscription' && !isLocalMode() && @@ -234,7 +234,7 @@ export const MessageInput = forwardRef( - } + } */}
{renderDropdown()} @@ -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 new file mode 100644 index 00000000..ddf5e300 --- /dev/null +++ b/frontend/src/components/thread/chat-input/usage-preview.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { X, Zap } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { isLocalMode } from '@/lib/config'; +import { Button } from '@/components/ui/button'; + +export interface UsagePreviewProps { + type: 'tokens' | 'upgrade'; + subscriptionData?: any; + onClose?: () => void; + onOpenUpgrade?: () => void; + hasMultiple?: boolean; + showIndicators?: boolean; + currentIndex?: number; + totalCount?: number; + onIndicatorClick?: (index: number) => void; +} + +export const UsagePreview: React.FC = ({ + type, + subscriptionData, + onClose, + onOpenUpgrade, + hasMultiple = false, + showIndicators = false, + currentIndex = 0, + totalCount = 1, + onIndicatorClick, +}) => { + if (isLocalMode()) return null; + + const formatCurrency = (amount: number) => { + return `$${amount.toFixed(2)}`; + }; + + const getUsageDisplay = () => { + if (!subscriptionData) return 'Loading usage...'; + + const current = subscriptionData.current_usage || 0; + const limit = subscriptionData.cost_limit || 0; + + if (limit === 0) return 'No usage limit set'; + + const isOverLimit = current > limit; + const usageText = `${formatCurrency(current)} / ${formatCurrency(limit)}`; + + if (isOverLimit) { + return `${usageText} (over limit)`; + } + + return usageText; + }; + + const isOverLimit = () => { + if (!subscriptionData) return false; + const current = subscriptionData.current_usage || 0; + const limit = subscriptionData.cost_limit || 0; + return current > limit; + }; + + return ( +
+ {/* Icon */} +
+ + + +
+ + {/* Content */} +
+ +

+ Upgrade for more usage +

+
+ + +
+ + {getUsageDisplay()} + + +
+ + {/* Apple-style notification indicators - only for multiple notification types */} + {showIndicators && totalCount === 2 && ( + + )} + + +
+ ); +}; \ No newline at end of file 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), {