Redesign AI chat and add pre-existing prompts. (#17787)

Redesigned AI-chat based on the following provided design.

<p align="center">
<img
src="https://github.com/user-attachments/assets/f10ebbd2-9ee9-402f-b246-6e8f8cedbd53"
width="225" />
</p>

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdullah.
2026-02-09 19:29:32 +05:00
committed by GitHub
parent 7fbd50e5a0
commit 4cdb7d61b2
13 changed files with 494 additions and 113 deletions
@@ -1,52 +1,19 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSparkles } from 'twenty-ui/display';
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
const StyledEmptyState = styled.div`
display: flex;
flex-direction: column;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
justify-content: center;
justify-content: flex-end;
height: 100%;
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2.5)};
display: flex;
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitle = styled.div`
font-size: ${({ theme }) => theme.font.size.lg};
color: ${({ theme }) => theme.font.color.primary};
font-weight: 600;
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
text-align: center;
max-width: 85%;
font-size: ${({ theme }) => theme.font.size.md};
`;
export const AIChatEmptyState = () => {
const theme = useTheme();
return (
<StyledEmptyState>
<StyledSparkleIcon>
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
</StyledSparkleIcon>
<StyledTitle>{t`Chat`}</StyledTitle>
<StyledDescription>
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
</StyledDescription>
<AIChatSuggestedPrompts />
</StyledEmptyState>
);
};
@@ -1,15 +1,14 @@
import { TextArea } from '@/ui/input/components/TextArea';
import styled from '@emotion/styled';
import { IconHistory, IconMessageCirclePlus } from 'twenty-ui/display';
import { IconHistory } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { DropZone } from '@/activities/files/components/DropZone';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
@@ -17,6 +16,7 @@ import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContext
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
@@ -25,7 +25,6 @@ import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { Button } from 'twenty-ui/input';
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
background: ${({ theme }) => theme.background.primary};
@@ -46,6 +45,46 @@ const StyledInputArea = styled.div`
background: ${({ theme }) => theme.background.primary};
`;
const StyledInputBox = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
min-height: 140px;
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
box-sizing: border-box;
&:focus-within {
border-color: ${({ theme }) => theme.color.blue};
box-shadow: 0px 0px 0px 3px ${({ theme }) => theme.color.transparent.blue2};
}
`;
const StyledTextAreaWrapper = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
`;
const StyledChatTextArea = styled(TextArea)`
&& {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
padding: 0;
}
&&:focus {
border: none;
box-shadow: none;
}
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
display: flex;
flex: 1;
@@ -59,7 +98,8 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
const StyledButtonsContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-end;
`;
export const AIChatTab = () => {
@@ -72,7 +112,6 @@ export const AIChatTab = () => {
useRecoilState(agentChatInputState);
const { uploadFiles } = useAIChatFileUpload();
const { createChatThread } = useCreateNewAIChatThread();
const { navigateCommandMenu } = useCommandMenu();
return (
@@ -115,7 +154,9 @@ export const AIChatTab = () => {
)}
</StyledScrollWrapper>
)}
{messages.length === 0 && !error && <AIChatEmptyState />}
{messages.length === 0 && !error && !isLoading && (
<AIChatEmptyState />
)}
{messages.length === 0 && error && !isLoading && (
<AIChatStandaloneError error={error} />
)}
@@ -123,37 +164,36 @@ export const AIChatTab = () => {
<StyledInputArea>
<AgentChatContextPreview />
<TextArea
textAreaId={AI_CHAT_INPUT_ID}
placeholder={t`Enter a question...`}
value={agentChatInput}
onChange={(value) => setAgentChatInput(value)}
minRows={1}
maxRows={20}
/>
<StyledButtonsContainer>
<Button
variant="secondary"
size="small"
Icon={IconHistory}
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
/>
<Button
variant="secondary"
size="small"
Icon={IconMessageCirclePlus}
onClick={() => createChatThread()}
/>
<AgentChatFileUploadButton />
<AIChatContextUsageButton />
<SendMessageButton />
</StyledButtonsContainer>
<StyledInputBox>
<StyledTextAreaWrapper>
<StyledChatTextArea
textAreaId={AI_CHAT_INPUT_ID}
placeholder={t`Ask, search or make anything...`}
value={agentChatInput}
onChange={(value) => setAgentChatInput(value)}
minRows={3}
maxRows={20}
/>
</StyledTextAreaWrapper>
<StyledButtonsContainer>
<AIChatContextUsageButton />
<IconButton
Icon={IconHistory}
variant="tertiary"
size="small"
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
ariaLabel={t`View Previous AI Chats`}
/>
<AgentChatFileUploadButton />
<SendMessageButton />
</StyledButtonsContainer>
</StyledInputBox>
</StyledInputArea>
</>
)}
@@ -19,14 +19,11 @@ const StyledContainer = styled.div`
const StyledTrigger = styled.div<{ hasUsage: boolean }>`
align-items: center;
background: transparent;
border: 1px solid ${({ theme }) => theme.background.transparent.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: ${({ hasUsage }) => (hasUsage ? 'pointer' : 'default')};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
padding: 0 ${({ theme }) => theme.spacing(2)};
justify-content: center;
min-width: 24px;
transition: background 0.1s ease;
&:hover {
@@ -137,7 +134,6 @@ export const AIChatContextUsageButton = () => {
<StyledContainer>
<StyledTrigger hasUsage={false}>
<ContextUsageProgressRing percentage={0} />
<StyledPercentage>0%</StyledPercentage>
</StyledTrigger>
</StyledContainer>
);
@@ -160,7 +156,6 @@ export const AIChatContextUsageButton = () => {
>
<StyledTrigger hasUsage={true}>
<ContextUsageProgressRing percentage={percentage} />
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
</StyledTrigger>
{isHovered && (
@@ -1,10 +1,11 @@
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import React, { useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconPaperclip } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { IconPlus } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const StyledFileUploadContainer = styled.div`
display: flex;
@@ -44,13 +45,14 @@ export const AgentChatFileUploadButton = () => {
onChange={handleFileInputChange}
/>
<Button
variant="secondary"
<IconButton
variant="tertiary"
size="small"
onClick={() => {
fileInputRef.current?.click();
}}
Icon={IconPaperclip}
Icon={IconPlus}
ariaLabel={t`Attach files`}
/>
</StyledFileUploadContainer>
);
@@ -13,7 +13,11 @@ const StyledSvg = styled.svg`
const StyledBackgroundCircle = styled.circle`
fill: none;
stroke: ${({ theme }) => theme.background.quaternary};
stroke: color-mix(
in srgb,
${({ theme }) => theme.border.color.strong} 50%,
${({ theme }) => theme.background.quaternary} 50%
);
`;
const StyledProgressCircle = styled.circle`
@@ -2,10 +2,10 @@ import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { Button } from 'twenty-ui/input';
import { IconArrowUp } from 'twenty-ui/display';
import { RoundedIconButton } from 'twenty-ui/input';
export const SendMessageButton = () => {
const agentChatInput = useRecoilValue(agentChatInputState);
@@ -27,14 +27,10 @@ export const SendMessageButton = () => {
});
return (
<Button
hotkeys={agentChatInput && !isLoading ? ['⏎'] : undefined}
<RoundedIconButton
Icon={IconArrowUp}
onClick={() => handleSendMessage()}
disabled={!agentChatInput || isLoading}
variant="primary"
accent="blue"
size="small"
title={t`Send`}
/>
);
};
@@ -0,0 +1,58 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useSetRecoilState } from 'recoil';
import { LightButton } from 'twenty-ui/input';
import {
DEFAULT_SUGGESTED_PROMPTS,
type SuggestedPrompt,
} from '@/ai/components/suggested-prompts/default-suggested-prompts';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledSuggestedPromptButton = styled(LightButton)`
width: 100%;
`;
const pickRandom = <T,>(items: T[]): T =>
items[Math.floor(Math.random() * items.length)];
export const AIChatSuggestedPrompts = () => {
const { t: resolveMessage } = useLingui();
const setAgentChatInput = useSetRecoilState(agentChatInputState);
const handleClick = (prompt: SuggestedPrompt) => {
const picked = pickRandom(prompt.prefillPrompts);
setAgentChatInput(resolveMessage(picked));
};
return (
<StyledContainer>
<StyledTitle>{t`What can I help you with?`}</StyledTitle>
{DEFAULT_SUGGESTED_PROMPTS.map((prompt) => (
<StyledSuggestedPromptButton
key={prompt.id}
Icon={prompt.Icon}
title={resolveMessage(prompt.label)}
accent="secondary"
onClick={() => handleClick(prompt)}
/>
))}
</StyledContainer>
);
};
@@ -0,0 +1,48 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import {
type IconComponent,
IconLayoutDashboard,
IconPlus,
IconSettingsAutomation,
} from 'twenty-ui/display';
export type SuggestedPrompt = {
id: string;
label: MessageDescriptor;
Icon: IconComponent;
prefillPrompts: MessageDescriptor[];
};
export const DEFAULT_SUGGESTED_PROMPTS: SuggestedPrompt[] = [
{
id: 'dashboard',
label: msg`Create a dashboard`,
Icon: IconLayoutDashboard,
prefillPrompts: [
msg`Create a dashboard with a chart of deal value by pipeline stage (New, Meeting, Proposal, Negotiation, Closed Won/Lost) for the current quarter, and a table of my top 10 open opportunities with amount, stage and expected close date.`,
msg`Build a dashboard that shows: (1) total pipeline value by stage for the last 3 months, (2) count of deals won vs lost per month, (3) average deal size. Use our standard pipeline stages.`,
msg`I need a dashboard for lead conversion: number of new leads by source this month, how many moved to opportunity, and conversion rate by source. Include a simple table and a bar chart.`,
],
},
{
id: 'workflow',
label: msg`Create a workflow`,
Icon: IconSettingsAutomation,
prefillPrompts: [
msg`When a deal's stage changes to Closed Won, create a task assigned to the deal owner, due 7 days after the close date, with title "Post-sale check-in" and the company name in the description.`,
msg`When a new lead is created with source "Website", assign it to the sales rep whose territory (by region/country) matches the lead's address; if no match, assign to the team lead.`,
msg`When any deal with amount over $100,000 has its stage or amount updated, send a notification to the sales channel with the deal name, company, new stage, amount and owner.`,
],
},
{
id: 'record',
label: msg`Create a record`,
Icon: IconPlus,
prefillPrompts: [
msg`Add a new company we're in touch with (e.g. name, website, industry). Details: `,
msg`Create a new contact and link them to a company. Details: `,
msg`Log a new deal (company, amount, stage, expected close). Details: `,
],
},
];
@@ -1,16 +1,17 @@
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconHandMove, IconSparkles } from 'twenty-ui/display';
import { IconEdit, IconSparkles } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated/graphql';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
const StyledIconButton = styled(IconButton)`
color: ${({ theme }) => theme.font.color.secondary};
`;
@@ -19,12 +20,8 @@ export const CommandMenuTopBarRightCornerIcon = () => {
const isMobile = useIsMobile();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuNavigationStack = useRecoilValue(
commandMenuNavigationStackState,
);
const { goBackFromCommandMenu } = useCommandMenuHistory();
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const { createChatThread } = useCreateNewAIChatThread();
if (isMobile || !isAiEnabled) {
return null;
@@ -35,8 +32,6 @@ export const CommandMenuTopBarRightCornerIcon = () => {
CommandMenuPages.ViewPreviousAIChats,
].includes(commandMenuPage);
const canGoBack = commandMenuNavigationStack.length > 1;
if (!isOnAskAIPage) {
return (
<StyledIconButton
@@ -48,16 +43,13 @@ export const CommandMenuTopBarRightCornerIcon = () => {
);
}
if (!canGoBack) {
return null;
}
return (
<StyledIconButton
Icon={IconHandMove}
Icon={IconEdit}
size="small"
variant="tertiary"
onClick={goBackFromCommandMenu}
onClick={() => createChatThread()}
ariaLabel={t`New conversation`}
/>
);
};