Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
40e9940
feat: add hover on rounded icon button
ehconitin Mar 20, 2026
b5c6dc1
Merge remote-tracking branch 'upstream/main' into ai-fast-follows-1
ehconitin Mar 23, 2026
681fb56
Add model selector dropdown to chat composer and fix ModelFamily enum…
ehconitin Mar 23, 2026
30f9584
Clean up model select: declarative array, remove unused import, fix o…
ehconitin Mar 23, 2026
2728444
Merge remote-tracking branch 'upstream/main' into ai-fast-follows-1
ehconitin Mar 24, 2026
9e4cc2d
Make chat model picker a user-level localStorage preference instead o…
ehconitin Mar 24, 2026
bca4d81
Validate stored model against current workspace before using it
ehconitin Mar 24, 2026
368a05b
chore: lint
ehconitin Mar 24, 2026
a74dad5
Refactor AIChatEditorSection to use resolved model ID and update stat…
ehconitin Mar 24, 2026
f4be8b3
Merge branch 'main' into ai-fast-follows-1
FelixMalfait Mar 24, 2026
e9b0351
refactor(ai): use workspace default chat model selection
ehconitin Mar 24, 2026
74988c1
Merge remote-tracking branch 'upstream/ai-fast-follows-1' into ai-fas…
ehconitin Mar 24, 2026
0247fd3
Merge remote-tracking branch 'upstream/main' into ai-fast-follows-1
ehconitin Mar 24, 2026
cac7686
revert: auto generated file
ehconitin Mar 24, 2026
37077c2
Merge remote-tracking branch 'upstream/main' into ai-fast-follows-1
ehconitin Mar 25, 2026
ca7d7b1
reviuew
ehconitin Mar 25, 2026
a4613fa
Merge branch 'main' into ai-fast-follows-1
FelixMalfait Mar 25, 2026
9d31090
Begin small refacto
FelixMalfait Mar 25, 2026
3d7346d
UI improvements
FelixMalfait Mar 25, 2026
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
Original file line number Diff line number Diff line change
@@ -1,67 +1,28 @@
import { AIChatBanner } from '@/ai/components/AIChatBanner';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconSparkles } from 'twenty-ui/display';
import { useQuery } from '@apollo/client/react';
import {
PermissionFlagType,
SubscriptionStatus,
BillingPortalSessionDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';

export const AIChatCreditsExhaustedMessage = () => {
const { redirect } = useRedirect();
const navigateSettings = useNavigateSettings();
const subscriptionStatus = useSubscriptionStatus();
const { endTrialPeriod, isLoading: isEndingTrial } =
useEndSubscriptionTrialPeriod();
const [isProcessing, setIsProcessing] = useState(false);

const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;

const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
usePermissionFlagMap();

const { data: billingPortalData, loading: isBillingPortalLoading } = useQuery(
BillingPortalSessionDocument,
{
variables: {
returnUrlPath: getSettingsPath(SettingsPath.Billing),
},
},
);

const openBillingPortal = () => {
if (
isDefined(billingPortalData) &&
isDefined(billingPortalData.billingPortalSession.url)
) {
redirect(billingPortalData.billingPortalSession.url);
}
const handleUpgradeClick = () => {
navigateSettings(SettingsPath.Billing);
};

const handleUpgradeClick = async () => {
if (!isTrialing) {
openBillingPortal();
return;
}

setIsProcessing(true);
const result = await endTrialPeriod();
setIsProcessing(false);

if (!result.success) {
openBillingPortal();
}
};

const isLoading = isEndingTrial || isBillingPortalLoading || isProcessing;

const message = hasPermissionToManageBilling
? isTrialing
? t`Free trial credits exhausted. Subscribe now to continue using AI features.`
Expand All @@ -79,8 +40,6 @@ export const AIChatCreditsExhaustedMessage = () => {
buttonOnClick={
hasPermissionToManageBilling ? handleUpgradeClick : undefined
}
isButtonDisabled={isLoading}
isButtonLoading={isLoading}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { styled } from '@linaria/react';
import { EditorContent } from '@tiptap/react';
import { LightButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';

import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
Expand All @@ -12,10 +11,17 @@ import { AIChatEditorFocusEffect } from '@/ai/components/internal/AIChatEditorFo
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor';
import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { Select } from '@/ui/input/components/Select';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';

const StyledInputArea = styled.div<{ isMobile: boolean }>`
align-items: flex-end;
Expand Down Expand Up @@ -102,24 +108,48 @@ const StyledRightButtonsContainer = styled.div`
gap: ${themeCssVariables.spacing[1]};
`;

const StyledReadOnlyModelButtonContainer = styled.div`
> * {
cursor: default;

&:hover,
&:active {
background: transparent;
}
}
`;

export const AIChatEditorSection = () => {
const isMobile = useIsMobile();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false);
const aiModels = useAtomStateValue(aiModelsState);
const { enabledModels } = useWorkspaceAiModelAvailability();
const setAgentChatUserSelectedModel = useSetAtomState(
agentChatUserSelectedModelState,
);
const { selectedModelId } = useAgentChatModelId();

const { editor, handleSendAndClear } = useAIChatEditor();

const workspaceSmartModel = aiModels.find(
(model) => model.modelId === currentWorkspace?.smartModel,
);

const resolvedDefaultModelId = enabledModels.find(
(model) =>
model.label === workspaceSmartModel?.label &&
model.providerName === workspaceSmartModel?.providerName,
)?.modelId;

const defaultPinnedOption = workspaceSmartModel
? {
value: null as string | null,
label: workspaceSmartModel.label,
Icon: getModelIcon(
workspaceSmartModel.modelFamily,
workspaceSmartModel.providerName,
),
contextualText: t`default`,
}
: undefined;

const smartModelOptions = enabledModels
.filter((model) => model.modelId !== resolvedDefaultModelId)
.map((model) => ({
value: model.modelId as string | null,
label: model.label,
Icon: getModelIcon(model.modelFamily, model.providerName),
}));

return (
<>
<AIChatEditorFocusEffect editor={editor} />
Expand All @@ -139,9 +169,16 @@ export const AIChatEditorSection = () => {
<AIChatContextUsageButton />
</StyledLeftButtonsContainer>
<StyledRightButtonsContainer>
<StyledReadOnlyModelButtonContainer>
<LightButton accent="tertiary" title={smartModelLabel} />
</StyledReadOnlyModelButtonContainer>
<Select
dropdownId="ai-chat-smart-model-select"
value={selectedModelId}
onChange={setAgentChatUserSelectedModel}
options={smartModelOptions}
pinnedOption={defaultPinnedOption}
selectSizeVariant="small"
withSearchInput
dropdownOffset={{ x: 0, y: 8 }}
/>
<SendMessageButton onSend={handleSendAndClear} />
</StyledRightButtonsContainer>
</StyledButtonsContainer>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
Expand All @@ -7,6 +7,12 @@ import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessa
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';

const StyledErrorWrapper = styled.div`
padding-top: ${themeCssVariables.spacing[3]};
`;

export const AIChatErrorUnderMessageList = () => {
const agentChatError = useAtomStateValue(agentChatErrorState);
Expand All @@ -31,5 +37,9 @@ export const AIChatErrorUnderMessageList = () => {
return null;
}

return <AIChatStandaloneError />;
return (
<StyledErrorWrapper>
<AIChatErrorRenderer error={agentChatError} />
</StyledErrorWrapper>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChat
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { normalizeAiSdkError } from '@/ai/utils/normalizeAiSdkError';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
Expand Down Expand Up @@ -119,7 +120,7 @@ export const AgentChatAiSdkStreamEffect = () => {
const setAgentChatError = useSetAtomState(agentChatErrorState);

useEffect(() => {
setAgentChatError(chatState.error);
setAgentChatError(normalizeAiSdkError(chatState.error));
}, [chatState.error, setAgentChatError]);

const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
Expand Down

This file was deleted.

This file was deleted.

6 changes: 6 additions & 0 deletions packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService';
Expand All @@ -40,6 +41,7 @@ export const useAgentChat = (
const setTokenPair = useSetAtomState(tokenPairState);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);

const { modelIdForRequest } = useAgentChatModelId();
const { getBrowsingContext } = useGetBrowsingContext();
const setCurrentAIChatThreadTitle = useSetAtomState(
currentAIChatThreadTitleState,
Expand Down Expand Up @@ -258,6 +260,9 @@ export const useAgentChat = (
body: {
threadId,
browsingContext,
...(isDefined(modelIdForRequest) && {
modelId: modelIdForRequest,
}),
},
},
);
Expand All @@ -273,6 +278,7 @@ export const useAgentChat = (
agentChatUploadedFiles,
setAgentChatUploadedFiles,
setAgentChatDraftsByThreadId,
modelIdForRequest,
]);

useListenToBrowserEvent({
Expand Down
23 changes: 23 additions & 0 deletions packages/twenty-front/src/modules/ai/hooks/useAgentChatModelId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { isDefined } from 'twenty-shared/utils';

import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';

export const useAgentChatModelId = () => {
const { enabledModels } = useWorkspaceAiModelAvailability();
const agentChatUserSelectedModel = useAtomStateValue(
agentChatUserSelectedModelState,
);

const isUserModelAvailable =
!isDefined(agentChatUserSelectedModel) ||
enabledModels.some((model) => model.modelId === agentChatUserSelectedModel);

const selectedModelId = isUserModelAvailable
? agentChatUserSelectedModel
: null;
const modelIdForRequest = selectedModelId ?? undefined;

return { selectedModelId, modelIdForRequest };
};
21 changes: 7 additions & 14 deletions packages/twenty-front/src/modules/ai/hooks/useAiModelOptions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type SelectOption } from 'twenty-ui/input';
import { isAutoSelectModelId } from 'twenty-shared/utils';

import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
Expand All @@ -16,13 +15,11 @@ export const useAiModelOptions = (): SelectOption<string>[] => {
)
.map((model) => ({
value: model.modelId,
label:
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL
? model.label
: model.modelFamilyLabel
? `${model.label} (${model.modelFamilyLabel})`
: model.label,
label: isAutoSelectModelId(model.modelId)
? model.label
: model.modelFamilyLabel
? `${model.label} (${model.modelFamilyLabel})`
: model.label,
}))
.sort((a, b) => a.label.localeCompare(b.label));
};
Expand All @@ -43,11 +40,7 @@ export const useAiModelLabel = (
return modelId;
}

if (
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL ||
!includeProvider
) {
if (isAutoSelectModelId(model.modelId) || !includeProvider) {
return model.label;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { isAutoSelectModelId } from 'twenty-shared/utils';

import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ClientAiModelConfig } from '~/generated-metadata/graphql';

const VIRTUAL_MODEL_IDS: Set<string> = new Set([
DEFAULT_SMART_MODEL,
DEFAULT_FAST_MODEL,
]);

const isVirtualModel = (modelId: string) => VIRTUAL_MODEL_IDS.has(modelId);

export const useWorkspaceAiModelAvailability = () => {
const aiModels = useAtomStateValue(aiModelsState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
Expand All @@ -23,7 +16,7 @@ export const useWorkspaceAiModelAvailability = () => {
modelId: string,
model?: ClientAiModelConfig,
): boolean => {
if (isVirtualModel(modelId)) {
if (isAutoSelectModelId(modelId)) {
return true;
}

Expand All @@ -35,7 +28,7 @@ export const useWorkspaceAiModelAvailability = () => {
};

const realModels = aiModels.filter(
(model) => !isVirtualModel(model.modelId) && !model.isDeprecated,
(model) => !isAutoSelectModelId(model.modelId) && !model.isDeprecated,
);

const enabledModels = realModels.filter((model) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';

export const agentChatUserSelectedModelState = createAtomState<string | null>({
key: 'ai/agentChatUserSelectedModel',
defaultValue: null,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
Loading
Loading