diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index f49e76d66..a87daff07 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -4,6 +4,8 @@ import { HedgehogMode } from "@components/HedgehogMode"; import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet"; import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView"; +import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; +import { useUsageLimitDetection } from "@features/billing/hooks/useUsageLimitDetection"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; import { InboxView } from "@features/inbox/components/InboxView"; @@ -15,6 +17,7 @@ import { TaskDetail } from "@features/task-detail/components/TaskDetail"; import { TaskInput } from "@features/task-detail/components/TaskInput"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { useConnectivity } from "@hooks/useConnectivity"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useIntegrations } from "@hooks/useIntegrations"; import { Box, Flex } from "@radix-ui/themes"; import { useCommandMenuStore } from "@stores/commandMenuStore"; @@ -38,7 +41,9 @@ export function MainLayout() { } = useShortcutsSheetStore(); const { data: tasks } = useTasks(); const { showPrompt, isChecking, check, dismiss } = useConnectivity(); + const billingEnabled = useFeatureFlag("posthog-code-billing"); + useUsageLimitDetection(); useIntegrations(); useTaskDeepLink(); @@ -99,6 +104,7 @@ export function MainLayout() { onToggleShortcutsSheet={toggleShortcutsSheet} /> + {billingEnabled && } ); diff --git a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx new file mode 100644 index 000000000..8722f86f2 --- /dev/null +++ b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx @@ -0,0 +1,46 @@ +import { useUsage } from "@features/billing/hooks/useUsage"; +import { isUsageExceeded } from "@features/billing/utils"; +import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useSeat } from "@hooks/useSeat"; +import { Box, Flex, Progress, Text } from "@radix-ui/themes"; + +export function SidebarUsageBar() { + const { isPro } = useSeat(); + const { usage } = useUsage({ enabled: !isPro }); + + if (isPro || !usage) return null; + + const usagePercent = Math.max( + usage.sustained.used_percent, + usage.burst.used_percent, + ); + const exceeded = isUsageExceeded(usage); + + const handleUpgrade = () => { + useSettingsDialogStore.getState().open("plan-usage"); + }; + + return ( + + + + + {exceeded ? "Limit reached" : `${Math.round(usagePercent)}% used`} + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx b/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx new file mode 100644 index 000000000..f15a1eed4 --- /dev/null +++ b/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx @@ -0,0 +1,47 @@ +import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; +import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { WarningCircle } from "@phosphor-icons/react"; +import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; + +export function UsageLimitModal() { + const isOpen = useUsageLimitStore((s) => s.isOpen); + const context = useUsageLimitStore((s) => s.context); + const hide = useUsageLimitStore((s) => s.hide); + + const handleUpgrade = () => { + hide(); + useSettingsDialogStore.getState().open("plan-usage"); + }; + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + + + Usage limit reached + + + + {context === "mid-task" + ? "You've hit your free plan usage limit. Your current task can't continue until usage resets or you upgrade to Pro." + : "You've reached your free plan usage limit. Upgrade to Pro for unlimited usage."} + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/billing/hooks/useUsage.ts b/apps/code/src/renderer/features/billing/hooks/useUsage.ts new file mode 100644 index 000000000..4cd77bf0e --- /dev/null +++ b/apps/code/src/renderer/features/billing/hooks/useUsage.ts @@ -0,0 +1,17 @@ +import { useTRPC } from "@renderer/trpc"; +import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; +import { useQuery } from "@tanstack/react-query"; + +const USAGE_REFETCH_INTERVAL_MS = 60_000; + +export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { + const trpc = useTRPC(); + const focused = useRendererWindowFocusStore((s) => s.focused); + const { data: usage, isLoading } = useQuery({ + ...trpc.llmGateway.usage.queryOptions(), + enabled, + refetchInterval: focused && enabled ? USAGE_REFETCH_INTERVAL_MS : false, + refetchIntervalInBackground: false, + }); + return { usage: usage ?? null, isLoading }; +} diff --git a/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts b/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts new file mode 100644 index 000000000..4e70273ec --- /dev/null +++ b/apps/code/src/renderer/features/billing/hooks/useUsageLimitDetection.ts @@ -0,0 +1,37 @@ +import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; +import { isUsageExceeded } from "@features/billing/utils"; +import { useSessionStore } from "@features/sessions/stores/sessionStore"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; +import { useSeat } from "@hooks/useSeat"; +import { useEffect, useRef } from "react"; +import { useUsage } from "./useUsage"; + +export function useUsageLimitDetection() { + const billingEnabled = useFeatureFlag("posthog-code-billing"); + const { isPro } = useSeat(); + const { usage } = useUsage({ enabled: billingEnabled && !isPro }); + const hasAlertedRef = useRef(false); + + useEffect(() => { + if (!billingEnabled || isPro || !usage) return; + + const exceeded = isUsageExceeded(usage); + + if (exceeded && !hasAlertedRef.current) { + hasAlertedRef.current = true; + + const sessions = useSessionStore.getState().sessions; + const hasActiveSession = Object.values(sessions).some( + (s) => s.status === "connected" && s.isPromptPending, + ); + + useUsageLimitStore + .getState() + .show(hasActiveSession ? "mid-task" : "idle"); + } + + if (!exceeded) { + hasAlertedRef.current = false; + } + }, [billingEnabled, isPro, usage]); +} diff --git a/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts b/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts new file mode 100644 index 000000000..90cd3609b --- /dev/null +++ b/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts @@ -0,0 +1,23 @@ +import { create } from "zustand"; + +type UsageLimitContext = "mid-task" | "idle"; + +interface UsageLimitState { + isOpen: boolean; + context: UsageLimitContext | null; +} + +interface UsageLimitActions { + show: (context: UsageLimitContext) => void; + hide: () => void; +} + +type UsageLimitStore = UsageLimitState & UsageLimitActions; + +export const useUsageLimitStore = create()((set) => ({ + isOpen: false, + context: null, + + show: (context) => set({ isOpen: true, context }), + hide: () => set({ isOpen: false, context: null }), +})); diff --git a/apps/code/src/renderer/features/billing/utils.ts b/apps/code/src/renderer/features/billing/utils.ts new file mode 100644 index 000000000..9179d31b1 --- /dev/null +++ b/apps/code/src/renderer/features/billing/utils.ts @@ -0,0 +1,11 @@ +interface UsageLimitCheck { + sustained: { exceeded: boolean }; + burst: { exceeded: boolean }; + is_rate_limited: boolean; +} + +export function isUsageExceeded(usage: UsageLimitCheck): boolean { + return ( + usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded + ); +} diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index 13f31ae65..90c29043a 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -248,31 +248,6 @@ export function GitIntegrationStep({ - {alternativeConnectedProject && selectedProject && ( - - - GitHub is already connected on{" "} - - {alternativeConnectedProject.name} - {" "} - ({alternativeConnectedProject.organization.name}). Switch to - that project, or click Connect to install a new integration - on {selectedProject.name}. - - - - - - )} - {/* Local folder picker */} + {alternativeConnectedProject && selectedProject && ( + + + GitHub is already connected on{" "} + + {alternativeConnectedProject.name} + {" "} + ({alternativeConnectedProject.organization.name}). Switch to + that project, or click{" "} + Connect GitHub below to install a + new integration on{" "} + {selectedProject.name}. + + + + + + )} + {/* GitHub integration */} { const navigateToArchived = useNavigationStore( (state) => state.navigateToArchived, ); + const billingEnabled = useFeatureFlag("posthog-code-billing"); return ( @@ -19,6 +22,7 @@ export const SidebarContent: React.FC = () => { + {billingEnabled && } {archivedTaskIds.size > 0 && (