Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/code/src/renderer/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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();
Comment thread
charlesvien marked this conversation as resolved.
useIntegrations();
useTaskDeepLink();

Expand Down Expand Up @@ -99,6 +104,7 @@ export function MainLayout() {
onToggleShortcutsSheet={toggleShortcutsSheet}
/>
<SettingsDialog />
{billingEnabled && <UsageLimitModal />}
<HedgehogMode />
</Flex>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Box px="2" py="1.5" className="shrink-0 border-gray-6 border-t">
<Flex direction="column" gap="1">
<Flex justify="between" align="center">
<Text size="1" className="text-gray-11">
{exceeded ? "Limit reached" : `${Math.round(usagePercent)}% used`}
</Text>
<button
type="button"
className="bg-transparent font-medium text-[11px] text-accent-11 transition-colors hover:text-accent-12"
onClick={handleUpgrade}
>
Upgrade
</button>
</Flex>
<Progress
value={Math.min(usagePercent, 100)}
size="1"
color={exceeded ? "red" : undefined}
/>
</Flex>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog.Root open={isOpen}>
<Dialog.Content
maxWidth="400px"
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Flex direction="column" gap="3">
<Flex align="center" gap="2">
<WarningCircle size={20} weight="bold" color="var(--red-9)" />
<Dialog.Title className="mb-0">Usage limit reached</Dialog.Title>
</Flex>
<Dialog.Description>
<Text size="2" color="gray">
{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."}
</Text>
</Dialog.Description>
<Flex justify="end" gap="3" mt="2">
<Button type="button" variant="soft" color="gray" onClick={hide}>
Not now
</Button>
<Button type="button" onClick={handleUpgrade}>
View upgrade options
</Button>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}
17 changes: 17 additions & 0 deletions apps/code/src/renderer/features/billing/hooks/useUsage.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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]);
}
23 changes: 23 additions & 0 deletions apps/code/src/renderer/features/billing/stores/usageLimitStore.ts
Original file line number Diff line number Diff line change
@@ -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<UsageLimitStore>()((set) => ({
isOpen: false,
context: null,

show: (context) => set({ isOpen: true, context }),
hide: () => set({ isOpen: false, context: null }),
}));
11 changes: 11 additions & 0 deletions apps/code/src/renderer/features/billing/utils.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,31 +248,6 @@ export function GitIntegrationStep({
</Flex>
</motion.div>

{alternativeConnectedProject && selectedProject && (
<Callout.Root color="blue" variant="soft">
<Callout.Text>
GitHub is already connected on{" "}
<Text weight="bold">
{alternativeConnectedProject.name}
</Text>{" "}
({alternativeConnectedProject.organization.name}). Switch to
that project, or click Connect to install a new integration
on <Text weight="bold">{selectedProject.name}</Text>.
</Callout.Text>
<Flex mt="2">
<Button
size="1"
variant="soft"
onClick={() =>
setSelectedProjectId(alternativeConnectedProject.id)
}
>
Switch to {alternativeConnectedProject.name}
</Button>
</Flex>
</Callout.Root>
)}

{/* Local folder picker */}
<motion.div
initial={{ opacity: 0, y: 8 }}
Expand Down Expand Up @@ -393,6 +368,33 @@ export function GitIntegrationStep({
</Box>
</motion.div>

{alternativeConnectedProject && selectedProject && (
<Callout.Root color="blue" variant="soft">
<Callout.Text>
GitHub is already connected on{" "}
<Text weight="bold">
{alternativeConnectedProject.name}
</Text>{" "}
({alternativeConnectedProject.organization.name}). Switch to
that project, or click{" "}
<Text weight="bold">Connect GitHub</Text> below to install a
new integration on{" "}
<Text weight="bold">{selectedProject.name}</Text>.
</Callout.Text>
<Flex mt="2">
<Button
size="1"
variant="soft"
onClick={() =>
setSelectedProjectId(alternativeConnectedProject.id)
}
>
Switch to {alternativeConnectedProject.name}
</Button>
</Flex>
</Callout.Root>
)}

{/* GitHub integration */}
<motion.div
initial={{ opacity: 0, y: 8 }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useUsage } from "@features/billing/hooks/useUsage";
import { useSeatStore } from "@features/billing/stores/seatStore";
import { useSeat } from "@hooks/useSeat";
import {
Expand All @@ -16,8 +17,6 @@ import {
Text,
} from "@radix-ui/themes";
import { Tooltip } from "@renderer/components/ui/Tooltip";
import { useTRPC } from "@renderer/trpc";
import { useQuery } from "@tanstack/react-query";
import { getPostHogUrl } from "@utils/urls";
import { useState } from "react";

Expand All @@ -38,14 +37,6 @@ function formatResetTime(seconds: number): string {
return `${days} days`;
}

function useUsage() {
const trpc = useTRPC();
const { data: usage, isLoading } = useQuery(
trpc.llmGateway.usage.queryOptions(),
);
return { usage: usage ?? null, isLoading };
}

export function PlanUsageSettings() {
const {
seat,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds";
import { SidebarUsageBar } from "@features/billing/components/SidebarUsageBar";
import { useFeatureFlag } from "@hooks/useFeatureFlag";
import { ArchiveIcon } from "@phosphor-icons/react";
import { Box, Flex } from "@radix-ui/themes";
import { useNavigationStore } from "@stores/navigationStore";
Expand All @@ -12,13 +14,15 @@ export const SidebarContent: React.FC = () => {
const navigateToArchived = useNavigationStore(
(state) => state.navigateToArchived,
);
const billingEnabled = useFeatureFlag("posthog-code-billing");

return (
<Flex direction="column" height="100%">
<Box flexGrow="1" overflow="hidden">
<SidebarMenu />
</Box>
<UpdateBanner />
{billingEnabled && <SidebarUsageBar />}
{archivedTaskIds.size > 0 && (
<Box className="shrink-0 border-gray-6 border-t">
<button
Expand Down
Loading