Skip to content
Open
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
16 changes: 13 additions & 3 deletions quadratic-api/src/routes/v0/teams.$uuid.PATCH.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { parseRequest } from '../../middleware/validateRequestSchema';
import { updateCustomer } from '../../stripe/stripe';
import type { RequestWithUser } from '../../types/Request';
import { ApiError } from '../../utils/ApiError';
import { getIsOnPaidPlan } from '../../utils/billing';
import { parseAndValidateClientDataKv } from '../../utils/teams';

export default [validateAccessToken, userMiddleware, handler];
Expand All @@ -31,15 +32,24 @@ async function handler(req: RequestWithUser, res: Response<ApiTypes['/v0/teams/:
} = req;
const {
userMakingRequest: { permissions },
team: { clientDataKv: existingClientDataKv, name: existingName, stripeCustomerId },
team,
} = await getTeam({ uuid, userId });
const { clientDataKv: existingClientDataKv, name: existingName, stripeCustomerId } = team;

// Can they make the edits theyre trying to make?
// Can they make the edits they're trying to make?
if (!permissions.includes('TEAM_EDIT')) {
throw new ApiError(403, 'User does not have permission to edit this team.');
}
if (settings && !permissions.includes('TEAM_MANAGE')) {
throw new ApiError(403, 'User does not have permission to edit this team’s settings.');
throw new ApiError(403, 'User does not have permission to edit this team settings.');
}

// Check if user is trying to disable analytics (enable privacy mode) without a paid plan
if (settings?.analyticsAi === false) {
const isOnPaidPlan = await getIsOnPaidPlan(team);
if (!isOnPaidPlan) {
throw new ApiError(403, 'AI privacy mode is only available on the Pro plan. Upgrade to disable analytics.');
}
}

// Validate existing data in the db
Expand Down
35 changes: 35 additions & 0 deletions quadratic-client/src/app/ui/components/AIMessageCounterBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useAIUsage } from '@/app/ui/hooks/useAIUsage';
import { useIsOnPaidPlan } from '@/app/ui/hooks/useIsOnPaidPlan';
import { memo } from 'react';

interface AIMessageCounterBarProps {
messageIndex?: number;
showEmptyChatPromptSuggestions?: boolean;
}

export const AIMessageCounterBar = memo(
({ messageIndex = 0, showEmptyChatPromptSuggestions = false }: AIMessageCounterBarProps) => {
const { isOnPaidPlan } = useIsOnPaidPlan();
const { data, messagesRemaining } = useAIUsage();

// Only show for free plans, when we have usage data, and NOT in initial/empty chat state
if (isOnPaidPlan || !data || messagesRemaining === null || (showEmptyChatPromptSuggestions && messageIndex === 0)) {
return null;
}

const handleUpgradeClick = () => {
window.open('/team/settings', '_blank');
};

return (
<div className="flex items-center justify-between px-2 py-1 text-xs text-muted-foreground">
<span>
{messagesRemaining} message{messagesRemaining !== 1 ? 's' : ''} left on your Free plan.
</span>
<button onClick={handleUpgradeClick} className="text-blue-600 hover:text-blue-800 hover:underline">
Upgrade now
</button>
</div>
);
}
);
65 changes: 45 additions & 20 deletions quadratic-client/src/app/ui/components/AIUserMessageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AIUsageExceeded } from '@/app/ui/components/AIUsageExceeded';
import { AIUserMessageFormAttachFileButton } from '@/app/ui/components/AIUserMessageFormAttachFileButton';
import { AIUserMessageFormConnectionsButton } from '@/app/ui/components/AIUserMessageFormConnectionsButton';
import ConditionalWrapper from '@/app/ui/components/ConditionalWrapper';
import { useIsOnPaidPlan } from '@/app/ui/hooks/useIsOnPaidPlan';
import { AIAnalystEmptyChatPromptSuggestions } from '@/app/ui/menus/AIAnalyst/AIAnalystEmptyChatPromptSuggestions';
import { ArrowUpwardIcon, BackspaceIcon, EditIcon } from '@/shared/components/Icons';
import { Button } from '@/shared/shadcn/ui/button';
Expand Down Expand Up @@ -377,6 +378,8 @@ export const AIUserMessageForm = memo(
setContext={setContext}
filesSupportedText={filesSupportedText}
uiContext={uiContext}
messageIndex={messageIndex}
showEmptyChatPromptSuggestions={showEmptyChatPromptSuggestions}
/>
</form>
</div>
Expand Down Expand Up @@ -418,27 +421,39 @@ interface CancelButtonProps {
show: boolean;
disabled: boolean;
abortPrompt: () => void;
messageIndex: number;
showEmptyChatPromptSuggestions?: boolean;
}
const CancelButton = memo(({ show, disabled, abortPrompt }: CancelButtonProps) => {
if (!show) {
return null;
}
const CancelButton = memo(
({ show, disabled, abortPrompt, messageIndex, showEmptyChatPromptSuggestions = false }: CancelButtonProps) => {
const { isOnPaidPlan } = useIsOnPaidPlan();

return (
<Button
size="sm"
variant="outline"
className="absolute -top-10 right-1/2 z-10 translate-x-1/2 bg-background"
onClick={(e) => {
e.stopPropagation();
abortPrompt();
}}
disabled={disabled}
>
<BackspaceIcon className="mr-1" /> Cancel generating
</Button>
);
});
if (!show) {
return null;
}

// Check if message counter is showing (same logic as AIMessageCounterBar)
// Message counter only shows for free users when not in empty chat state
const isMessageCounterShowing = !isOnPaidPlan && !(showEmptyChatPromptSuggestions && messageIndex === 0);

return (
<Button
size="sm"
variant="outline"
className={`absolute right-1/2 z-10 translate-x-1/2 bg-background ${
isMessageCounterShowing ? '-top-16' : '-top-10'
}`}
onClick={(e) => {
e.stopPropagation();
abortPrompt();
}}
disabled={disabled}
>
<BackspaceIcon className="mr-1" /> Cancel generating
</Button>
);
}
);

interface AIUserMessageFormFooterProps {
disabled: boolean;
Expand All @@ -456,6 +471,8 @@ interface AIUserMessageFormFooterProps {
setContext?: React.Dispatch<React.SetStateAction<Context>>;
filesSupportedText: string;
uiContext: AIUserMessageFormProps['uiContext'];
messageIndex: number;
showEmptyChatPromptSuggestions?: boolean;
}
const AIUserMessageFormFooter = memo(
({
Expand All @@ -474,6 +491,8 @@ const AIUserMessageFormFooter = memo(
setContext,
filesSupportedText,
uiContext,
messageIndex,
showEmptyChatPromptSuggestions,
}: AIUserMessageFormFooterProps) => {
const handleClickSubmit = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
Expand Down Expand Up @@ -544,7 +563,13 @@ const AIUserMessageFormFooter = memo(
</div>
</div>

<CancelButton show={loading} disabled={cancelDisabled} abortPrompt={abortPrompt} />
<CancelButton
show={loading}
disabled={cancelDisabled}
abortPrompt={abortPrompt}
messageIndex={messageIndex}
showEmptyChatPromptSuggestions={showEmptyChatPromptSuggestions}
/>
</>
);
}
Expand Down

This file was deleted.

60 changes: 60 additions & 0 deletions quadratic-client/src/app/ui/hooks/useAIUsage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { aiAnalystCurrentChatMessagesCountAtom } from '@/app/atoms/aiAnalystAtom';
import { aiAssistantCurrentChatMessagesCountAtom } from '@/app/atoms/codeEditorAtom';
import { editorInteractionStateTeamUuidAtom } from '@/app/atoms/editorInteractionStateAtom';
import { apiClient } from '@/shared/api/apiClient';
import { useCallback, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';

export interface AIUsageData {
exceededBillingLimit: boolean;
billingLimit?: number;
currentPeriodUsage?: number;
}

export const useAIUsage = () => {
const teamUuid = useRecoilValue(editorInteractionStateTeamUuidAtom);
const aiAnalystMessageCount = useRecoilValue(aiAnalystCurrentChatMessagesCountAtom);
const aiAssistantMessageCount = useRecoilValue(aiAssistantCurrentChatMessagesCountAtom);
const [data, setData] = useState<AIUsageData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const fetchAIUsage = useCallback(async () => {
if (!teamUuid) return;

setLoading(true);
setError(null);

try {
const result = await apiClient.teams.billing.aiUsage(teamUuid);
setData(result);
} catch (err) {
console.error('Failed to fetch AI usage:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch AI usage');
} finally {
setLoading(false);
}
}, [teamUuid]);

useEffect(() => {
fetchAIUsage();
}, [fetchAIUsage]);

// Refetch usage when message counts change (new messages sent)
useEffect(() => {
fetchAIUsage();
}, [aiAnalystMessageCount, aiAssistantMessageCount, fetchAIUsage]);

const messagesRemaining =
data?.billingLimit && data?.currentPeriodUsage !== undefined
? Math.max(0, data.billingLimit - data.currentPeriodUsage - 1)
: null;

return {
data,
loading,
error,
refetch: fetchAIUsage,
messagesRemaining,
};
};
38 changes: 26 additions & 12 deletions quadratic-client/src/app/ui/menus/AIAnalyst/AIAnalyst.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from '@/app/atoms/aiAnalystAtom';
import { presentationModeAtom } from '@/app/atoms/gridSettingsAtom';
import { events } from '@/app/events/events';
import { AIUserMessageFormDisclaimer } from '@/app/ui/components/AIUserMessageFormDisclaimer';
import { AIMessageCounterBar } from '@/app/ui/components/AIMessageCounterBar';
import { ResizeControl } from '@/app/ui/components/ResizeControl';
import { AIAnalystChatHistory } from '@/app/ui/menus/AIAnalyst/AIAnalystChatHistory';
import { AIAnalystGetChatName } from '@/app/ui/menus/AIAnalyst/AIAnalystGetChatName';
Expand Down Expand Up @@ -93,17 +93,31 @@ export const AIAnalyst = memo(() => {
<>
<AIAnalystMessages textareaRef={textareaRef} />

<div className={'grid grid-rows-[1fr_auto] px-2 py-0.5'}>
<AIAnalystUserMessageForm
ref={textareaRef}
autoFocusRef={autoFocusRef}
textareaRef={textareaRef}
messageIndex={messagesCount}
showEmptyChatPromptSuggestions={true}
/>

<AIUserMessageFormDisclaimer />
</div>
{messagesCount === 0 ? (
// Original layout for empty state - completely unaltered
<div className={'grid grid-rows-[1fr_auto] px-2 py-0.5'}>
<AIAnalystUserMessageForm
ref={textareaRef}
autoFocusRef={autoFocusRef}
textareaRef={textareaRef}
messageIndex={messagesCount}
showEmptyChatPromptSuggestions={true}
/>
</div>
) : (
// Layout with message counter above chat box for non-empty state
<div className={'grid grid-rows-[1fr_auto_auto] px-2 py-0.5'}>
<div></div>
<AIMessageCounterBar messageIndex={messagesCount} showEmptyChatPromptSuggestions={true} />
<AIAnalystUserMessageForm
ref={textareaRef}
autoFocusRef={autoFocusRef}
textareaRef={textareaRef}
messageIndex={messagesCount}
showEmptyChatPromptSuggestions={true}
/>
</div>
)}
</>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { aiAssistantMessagesCountAtom } from '@/app/atoms/codeEditorAtom';
import { AIUserMessageFormDisclaimer } from '@/app/ui/components/AIUserMessageFormDisclaimer';
import { AIMessageCounterBar } from '@/app/ui/components/AIMessageCounterBar';
import { AIAssistantMessages } from '@/app/ui/menus/CodeEditor/AIAssistant/AIAssistantMessages';
import { AIAssistantUserMessageForm } from '@/app/ui/menus/CodeEditor/AIAssistant/AIAssistantUserMessageForm';
import { memo, useRef } from 'react';
Expand All @@ -15,13 +15,13 @@ export const AIAssistant = memo(() => {
<AIAssistantMessages textareaRef={textareaRef} />

<div className="flex h-full flex-col justify-end px-2 py-0.5">
<AIMessageCounterBar messageIndex={messagesCount} showEmptyChatPromptSuggestions={false} />
<AIAssistantUserMessageForm
ref={textareaRef}
autoFocusRef={autoFocusRef}
textareaRef={textareaRef}
messageIndex={messagesCount}
/>
<AIUserMessageFormDisclaimer />
</div>
</div>
);
Expand Down
22 changes: 21 additions & 1 deletion quadratic-client/src/routes/teams.$teamUuid.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,26 @@ export const Component = () => {
</Type>

<div>
{!isOnPaidPlan && (
<p className="mb-3 text-sm text-muted-foreground">
<button
onClick={() => {
trackEvent('[TeamSettings].upgradeToProClicked', {
team_uuid: team.uuid,
source: 'privacy_section',
});
apiClient.teams.billing.getCheckoutSessionUrl(team.uuid).then((data) => {
window.location.href = data.url;
});
}}
className="font-semibold text-foreground underline hover:text-primary"
disabled={!canManageBilling}
>
Upgrade to Pro
</button>{' '}
to enable AI privacy mode.
</p>
)}
<SettingControl
label="Improve AI results"
description={
Expand All @@ -341,7 +361,7 @@ export const Component = () => {
}}
checked={optimisticSettings.analyticsAi}
className="rounded-lg border border-border p-4 shadow-sm"
disabled={!teamPermissions.includes('TEAM_MANAGE')}
disabled={!teamPermissions.includes('TEAM_MANAGE') || !isOnPaidPlan}
/>
<div className="mt-4">
<p className="text-sm text-muted-foreground">
Expand Down
Loading