feat(billing) - facilitate top up in ai chat (#21645)
Today, when a trialing user hits their AI usage cap inside the Ask AI chat, ending the trial bounces them to the Stripe billing portal (and, for card-less users, loses their place in the conversation). This PR makes activating a paid plan / topping up credits feel seamless from within the chat: Trial users with a card on file activate their subscription in place, without leaving the app. Trial users without a card are sent to the Stripe payment-method portal and, on return, the trial is ended automatically and they're dropped back into the exact Ask AI thread they came from. Credit-exhaustion and trial banners now reflect whether a payment method exists (Add Credit Card vs Subscribe Now / End Trial Period) and upgrade inline via a confirmation modal instead of redirecting to Settings. Uploading Screen Recording 2026-06-16 at 07.51.12.mov… https://github.com/user-attachments/assets/4ea77273-da63-4b32-b6f1-5ac9e9560651 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21645?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+27
-78
@@ -1,21 +1,15 @@
|
||||
import { AiChatBanner } from '@/ai/components/AiChatBanner';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useGetNextResourceCreditPrice } from '@/settings/billing/hooks/useGetNextResourceCreditPrice';
|
||||
import { useAiChatEndTrialPeriod } from '@/ai/hooks/useAiChatEndTrialPeriod';
|
||||
import { StartSubscriptionConfirmationModal } from '@/settings/billing/components/StartSubscriptionConfirmationModal';
|
||||
import { useCreditUpgradeAction } from '@/settings/billing/hooks/useCreditUpgradeAction';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
PermissionFlagType,
|
||||
SetResourceCreditSubscriptionPriceDocument,
|
||||
SubscriptionInterval,
|
||||
SubscriptionStatus,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -24,50 +18,32 @@ const AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID =
|
||||
'ai-chat-upgrade-credit-plan-modal';
|
||||
|
||||
export const AIChatNoMoreBillingCreditsBanner = () => {
|
||||
const { t } = useLingui();
|
||||
const subscriptionStatus = useSubscriptionStatus();
|
||||
|
||||
const { openModal } = useModal();
|
||||
const { endTrialPeriod, isLoading: isEndTrialLoading } =
|
||||
useEndSubscriptionTrialPeriod();
|
||||
|
||||
const nextPrice = useGetNextResourceCreditPrice();
|
||||
|
||||
const { formatNumber } = useNumberFormat();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
|
||||
const [setResourceCreditSubscriptionPrice, { loading: isUpgrading }] =
|
||||
useMutation(SetResourceCreditSubscriptionPriceDocument);
|
||||
|
||||
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
|
||||
const { [PermissionFlagType.BILLING]: hasPermissionToManageBilling } =
|
||||
usePermissionFlagMap();
|
||||
|
||||
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
|
||||
|
||||
const { endTrialPeriodFromAiChat, isEndTrialLoading, hasPaymentMethod } =
|
||||
useAiChatEndTrialPeriod();
|
||||
|
||||
const {
|
||||
nextPrice,
|
||||
nextResourceCreditsAmount,
|
||||
nextResourceCreditPrice,
|
||||
nextTierInterval,
|
||||
upgradeCreditPlan,
|
||||
isUpgrading,
|
||||
} = useCreditUpgradeAction();
|
||||
|
||||
if (!hasPermissionToManageBilling) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
|
||||
|
||||
const nextResourceCreditsAmount = isDefined(nextPrice)
|
||||
? formatNumber(nextPrice.creditAmount ?? 0, {
|
||||
abbreviate: true,
|
||||
decimals: 2,
|
||||
})
|
||||
: null;
|
||||
|
||||
const nextResourceCreditPrice = isDefined(nextPrice)
|
||||
? formatNumber((nextPrice.unitAmount ?? 0) / 100)
|
||||
: null;
|
||||
|
||||
const nextTierInterval = isDefined(nextPrice)
|
||||
? nextPrice.recurringInterval === SubscriptionInterval.Month
|
||||
? t`month`
|
||||
: t`year`
|
||||
: null;
|
||||
|
||||
const message = isTrialing
|
||||
? t`You've hit your usage limit. Subscribe for more usage.`
|
||||
: isDefined(nextPrice)
|
||||
@@ -75,7 +51,9 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
|
||||
: t`You've hit your usage limit. \nReach to our support team to upgrade.`;
|
||||
|
||||
const buttonTitle = isTrialing
|
||||
? t`Subscribe Now`
|
||||
? hasPaymentMethod === false
|
||||
? t`Add Credit Card`
|
||||
: t`Subscribe Now`
|
||||
: isDefined(nextPrice)
|
||||
? t`Upgrade`
|
||||
: undefined;
|
||||
@@ -86,32 +64,6 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
|
||||
? () => openModal(AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID)
|
||||
: undefined;
|
||||
|
||||
const handleUpgradeConfirm = async () => {
|
||||
if (!isDefined(nextPrice)) return;
|
||||
try {
|
||||
const { data } = await setResourceCreditSubscriptionPrice({
|
||||
variables: { priceId: nextPrice.stripePriceId },
|
||||
});
|
||||
if (
|
||||
isDefined(
|
||||
data?.setResourceCreditSubscriptionPrice.currentBillingSubscription,
|
||||
) &&
|
||||
isDefined(currentWorkspace)
|
||||
) {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
currentBillingSubscription:
|
||||
data.setResourceCreditSubscriptionPrice.currentBillingSubscription,
|
||||
billingSubscriptions:
|
||||
data.setResourceCreditSubscriptionPrice.billingSubscriptions,
|
||||
});
|
||||
}
|
||||
enqueueSuccessSnackBar({ message: t`Credit plan upgraded.` });
|
||||
} catch {
|
||||
enqueueErrorSnackBar({ message: t`Failed to upgrade credit plan.` });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AiChatBanner
|
||||
@@ -124,13 +76,10 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
|
||||
}
|
||||
/>
|
||||
{isTrialing && (
|
||||
<ConfirmationModal
|
||||
<StartSubscriptionConfirmationModal
|
||||
modalInstanceId={AI_CHAT_END_TRIAL_PERIOD_MODAL_ID}
|
||||
title={t`Start Your Subscription`}
|
||||
subtitle={t`We will activate your paid plan. Do you want to proceed?`}
|
||||
onConfirmClick={endTrialPeriod}
|
||||
confirmButtonText={t`Confirm`}
|
||||
confirmButtonAccent="blue"
|
||||
hasPaymentMethod={hasPaymentMethod}
|
||||
onConfirmClick={endTrialPeriodFromAiChat}
|
||||
loading={isEndTrialLoading}
|
||||
/>
|
||||
)}
|
||||
@@ -139,7 +88,7 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
|
||||
modalInstanceId={AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID}
|
||||
title={t`Get more credits`}
|
||||
subtitle={t`Upgrade to ${nextResourceCreditsAmount ?? ''} credits for $${nextResourceCreditPrice ?? ''}/${nextTierInterval ?? ''}.`}
|
||||
onConfirmClick={handleUpgradeConfirm}
|
||||
onConfirmClick={upgradeCreditPlan}
|
||||
confirmButtonText={t`Upgrade`}
|
||||
confirmButtonAccent="blue"
|
||||
loading={isUpgrading}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { AiChatBanner } from '@/ai/components/AiChatBanner';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { IconSparkles } from 'twenty-ui/display';
|
||||
import {
|
||||
PermissionFlagType,
|
||||
SubscriptionStatus,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const AiChatCreditsExhaustedMessage = () => {
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const subscriptionStatus = useSubscriptionStatus();
|
||||
|
||||
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
|
||||
|
||||
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
|
||||
usePermissionFlagMap();
|
||||
|
||||
const handleUpgradeClick = () => {
|
||||
navigateSettings(SettingsPath.Billing);
|
||||
};
|
||||
|
||||
const message = hasPermissionToManageBilling
|
||||
? isTrialing
|
||||
? t`Free trial credits exhausted. Subscribe now to continue using AI features.`
|
||||
: t`Credits exhausted. Upgrade your plan to get more credits.`
|
||||
: t`Credits exhausted. Please contact your workspace admin to upgrade.`;
|
||||
|
||||
const buttonTitle = isTrialing ? t`Subscribe Now` : t`Upgrade Plan`;
|
||||
|
||||
return (
|
||||
<AiChatBanner
|
||||
message={message}
|
||||
variant="warning"
|
||||
buttonTitle={hasPermissionToManageBilling ? buttonTitle : undefined}
|
||||
buttonIcon={IconSparkles}
|
||||
buttonOnClick={
|
||||
hasPermissionToManageBilling ? handleUpgradeClick : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AiChatApiKeyNotConfiguredMessage } from '@/ai/components/AiChatApiKeyNotConfiguredMessage';
|
||||
import { AiChatCreditsExhaustedMessage } from '@/ai/components/AiChatCreditsExhaustedMessage';
|
||||
import { AiChatErrorMessage } from '@/ai/components/AiChatErrorMessage';
|
||||
import { type AiChatError } from '@/ai/types/AiChatError';
|
||||
import { AiChatErrorCode } from '@/ai/utils/aiChatErrorCode';
|
||||
@@ -11,7 +10,8 @@ type AiChatErrorRendererProps = {
|
||||
|
||||
export const AiChatErrorRenderer = ({ error }: AiChatErrorRendererProps) => {
|
||||
if (isGraphqlErrorOfType(error, AiChatErrorCode.BILLING_CREDITS_EXHAUSTED)) {
|
||||
return <AiChatCreditsExhaustedMessage />;
|
||||
//Handle by AIChatNoMoreBillingCreditsBanner
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isGraphqlErrorOfType(error, AiChatErrorCode.API_KEY_NOT_CONFIGURED)) {
|
||||
|
||||
@@ -17,11 +17,16 @@ const StyledScrollWrapperContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
overflow-y: auto;
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
position: relative;
|
||||
width: calc(100% - 24px);
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledMessageListContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const AiChatTabMessageList = () => {
|
||||
@@ -46,9 +51,11 @@ export const AiChatTabMessageList = () => {
|
||||
}}
|
||||
>
|
||||
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
|
||||
<AiChatNonLastMessageIdsList />
|
||||
<AiChatLastMessageWithStreamingState />
|
||||
<AiChatErrorUnderMessageList />
|
||||
<StyledMessageListContent>
|
||||
<AiChatNonLastMessageIdsList />
|
||||
<AiChatLastMessageWithStreamingState />
|
||||
<AiChatErrorUnderMessageList />
|
||||
</StyledMessageListContent>
|
||||
<AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect />
|
||||
<AgentChatScrollToBottomOnMountLayoutEffect />
|
||||
</ScrollWrapper>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ASK_AI_THREAD_ID_QUERY_PARAM = 'askAiThreadId';
|
||||
@@ -20,10 +20,12 @@ import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMess
|
||||
import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState';
|
||||
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
|
||||
import { AiChatErrorCode } from '@/ai/utils/aiChatErrorCode';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { sseClientState } from '@/sse-db-event/states/sseClientState';
|
||||
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { BillingProductKey } from '~/generated-metadata/graphql';
|
||||
|
||||
const THROTTLE_MS = 100;
|
||||
|
||||
@@ -324,6 +326,35 @@ export const useAgentChatSubscription = (threadId: string | null) => {
|
||||
}
|
||||
|
||||
case 'credits-exhausted': {
|
||||
//TODO : add real time on currentUser
|
||||
store.set(currentWorkspaceState.atom, (currentWorkspace) => {
|
||||
const currentBillingSubscription =
|
||||
currentWorkspace?.currentBillingSubscription;
|
||||
const billingSubscriptionItems =
|
||||
currentBillingSubscription?.billingSubscriptionItems;
|
||||
|
||||
if (
|
||||
!isDefined(currentWorkspace) ||
|
||||
!isDefined(currentBillingSubscription) ||
|
||||
!isDefined(billingSubscriptionItems)
|
||||
) {
|
||||
return currentWorkspace;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentWorkspace,
|
||||
currentBillingSubscription: {
|
||||
...currentBillingSubscription,
|
||||
billingSubscriptionItems: billingSubscriptionItems.map((item) =>
|
||||
item.billingProduct.metadata?.['productKey'] ===
|
||||
BillingProductKey.RESOURCE_CREDIT
|
||||
? { ...item, hasReachedCurrentPeriodCap: true }
|
||||
: item,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const noMoreCreditsError = new Error(
|
||||
'Chat stopped: no more available credits.',
|
||||
) as Error & { code?: string };
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { buildAskAiThreadRedirectPath } from '@/ai/utils/buildAskAiThreadRedirectPath';
|
||||
import { billingHasPaymentMethodSelector } from '@/settings/billing/states/billingHasPaymentMethodSelector';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export const useAiChatEndTrialPeriod = () => {
|
||||
const location = useLocation();
|
||||
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
|
||||
|
||||
const { endTrialPeriod, isLoading } = useEndSubscriptionTrialPeriod();
|
||||
const billingHasPaymentMethod = useAtomStateValue(
|
||||
billingHasPaymentMethodSelector,
|
||||
);
|
||||
|
||||
const endTrialPeriodFromAiChat = async () => {
|
||||
await endTrialPeriod({
|
||||
finalRedirectPath: buildAskAiThreadRedirectPath({
|
||||
pathname: location.pathname,
|
||||
search: location.search,
|
||||
threadId: currentAiChatThread,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
endTrialPeriodFromAiChat,
|
||||
isEndTrialLoading: isLoading,
|
||||
hasPaymentMethod: billingHasPaymentMethod,
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
import { agentChatDraftsByThreadIdState } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
|
||||
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
|
||||
import { useSwitchAgentChatThreadWithDraft } from '@/ai/hooks/useSwitchAgentChatThreadWithDraft';
|
||||
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
|
||||
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useStore } from 'jotai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -23,13 +20,7 @@ export const useAiChatThreadClick = (
|
||||
const setThreadIdCreatedFromDraft = useSetAtomState(
|
||||
threadIdCreatedFromDraftState,
|
||||
);
|
||||
const [currentAiChatThread, setCurrentAiChatThread] = useAtomState(
|
||||
currentAiChatThreadState,
|
||||
);
|
||||
const setAgentChatInput = useSetAtomState(agentChatInputState);
|
||||
const setAgentChatDraftsByThreadId = useSetAtomState(
|
||||
agentChatDraftsByThreadIdState,
|
||||
);
|
||||
const { switchThreadWithDraft } = useSwitchAgentChatThreadWithDraft();
|
||||
const threadTitleFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
||||
currentAiChatThreadTitleComponentFamilyState,
|
||||
);
|
||||
@@ -41,21 +32,8 @@ export const useAiChatThreadClick = (
|
||||
|
||||
const handleThreadClick = (thread: AgentChatThread) => {
|
||||
setThreadIdCreatedFromDraft(null);
|
||||
const isSameThread = thread.id === currentAiChatThread;
|
||||
|
||||
if (currentAiChatThread !== null) {
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
...prev,
|
||||
[currentAiChatThread]: store.get(agentChatInputState.atom),
|
||||
}));
|
||||
}
|
||||
setCurrentAiChatThread(thread.id);
|
||||
|
||||
if (!isSameThread) {
|
||||
const newDraft =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[thread.id] ?? '';
|
||||
setAgentChatInput(newDraft);
|
||||
}
|
||||
switchThreadWithDraft(thread.id);
|
||||
|
||||
const clickedFamilyKey = { threadId: thread.id };
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
|
||||
import { useSwitchAgentChatThreadWithDraft } from '@/ai/hooks/useSwitchAgentChatThreadWithDraft';
|
||||
import { agentChatVisibleThreadsSelector } from '@/ai/states/selectors/agentChatVisibleThreadsSelector';
|
||||
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
export const useOpenAskAiThread = () => {
|
||||
const agentChatVisibleThreads = useAtomStateValue(
|
||||
agentChatVisibleThreadsSelector,
|
||||
);
|
||||
const { switchThreadWithDraft } = useSwitchAgentChatThreadWithDraft();
|
||||
|
||||
const { handleThreadClick } = useAiChatThreadClick({
|
||||
resetNavigationStack: true,
|
||||
});
|
||||
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
|
||||
|
||||
const openAskAiThread = (threadId: string) => {
|
||||
const thread = agentChatVisibleThreads.find(
|
||||
(visibleThread) => visibleThread.id === threadId,
|
||||
);
|
||||
|
||||
if (isDefined(thread)) {
|
||||
handleThreadClick(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidUuid(threadId)) {
|
||||
switchThreadWithDraft(threadId);
|
||||
}
|
||||
|
||||
openAskAiPage({ resetNavigationStack: true });
|
||||
};
|
||||
|
||||
return { openAskAiThread };
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { agentChatDraftsByThreadIdState } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useStore } from 'jotai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useSwitchAgentChatThreadWithDraft = () => {
|
||||
const [currentAiChatThread, setCurrentAiChatThread] = useAtomState(
|
||||
currentAiChatThreadState,
|
||||
);
|
||||
const setAgentChatInput = useSetAtomState(agentChatInputState);
|
||||
const setAgentChatDraftsByThreadId = useSetAtomState(
|
||||
agentChatDraftsByThreadIdState,
|
||||
);
|
||||
const store = useStore();
|
||||
|
||||
const switchThreadWithDraft = (toThreadId: string) => {
|
||||
const isSameThread = toThreadId === currentAiChatThread;
|
||||
|
||||
if (isDefined(currentAiChatThread)) {
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
...prev,
|
||||
[currentAiChatThread]: store.get(agentChatInputState.atom),
|
||||
}));
|
||||
}
|
||||
|
||||
setCurrentAiChatThread(toThreadId);
|
||||
|
||||
if (!isSameThread) {
|
||||
const destinationDraft =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[toThreadId] ?? '';
|
||||
setAgentChatInput(destinationDraft);
|
||||
}
|
||||
};
|
||||
|
||||
return { switchThreadWithDraft };
|
||||
};
|
||||
@@ -1,47 +1,25 @@
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { useSwitchAgentChatThreadWithDraft } from '@/ai/hooks/useSwitchAgentChatThreadWithDraft';
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { shouldFocusChatEditorState } from '@/ai/states/shouldFocusChatEditorState';
|
||||
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
|
||||
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
|
||||
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
export const useSwitchToNewAiChat = () => {
|
||||
const setThreadIdCreatedFromDraft = useSetAtomState(
|
||||
threadIdCreatedFromDraftState,
|
||||
);
|
||||
const [currentAiChatThread, setCurrentAiChatThread] = useAtomState(
|
||||
currentAiChatThreadState,
|
||||
);
|
||||
const setAgentChatInput = useSetAtomState(agentChatInputState);
|
||||
const setAgentChatDraftsByThreadId = useSetAtomState(
|
||||
agentChatDraftsByThreadIdState,
|
||||
);
|
||||
const { switchThreadWithDraft } = useSwitchAgentChatThreadWithDraft();
|
||||
const store = useStore();
|
||||
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
|
||||
|
||||
const switchToNewChat = () => {
|
||||
setThreadIdCreatedFromDraft(null);
|
||||
const newChatDraft =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
||||
] ?? '';
|
||||
if (currentAiChatThread !== null) {
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
...prev,
|
||||
[currentAiChatThread]: store.get(agentChatInputState.atom),
|
||||
}));
|
||||
}
|
||||
store.set(hasTriggeredCreateForDraftState.atom, false);
|
||||
setCurrentAiChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
|
||||
setAgentChatInput(newChatDraft);
|
||||
switchThreadWithDraft(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
|
||||
openAskAiPage();
|
||||
store.set(shouldFocusChatEditorState.atom, true);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ASK_AI_THREAD_ID_QUERY_PARAM } from '@/ai/constants/AskAiThreadIdQueryParam';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const buildAskAiThreadRedirectPath = ({
|
||||
pathname,
|
||||
search,
|
||||
threadId,
|
||||
}: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
threadId: string | null;
|
||||
}): string => {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
|
||||
if (isNonEmptyString(threadId)) {
|
||||
searchParams.set(ASK_AI_THREAD_ID_QUERY_PARAM, threadId);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return queryString.length > 0 ? `${pathname}?${queryString}` : pathname;
|
||||
};
|
||||
Reference in New Issue
Block a user