[AI] Add thread actions, filters, and archive support (#20068)

## PR Description

### Summary
- Add AI chat thread actions: rename, archive (soft-delete via
`deletedAt`), and hard-delete with confirmation.
- Add chat thread filtering by status (active/archived/all), group-by
mode, and last activity.
- Rework drawer/side-panel thread lists to share thread sections, item
menus, archive icons, and empty-state behavior.
- Extend server chat thread model/API with `deletedAt`, mutations,
broadcasts, and archive-aware stream guards.

### Decisions
- Two-stage lifecycle: Archive sets `deletedAt` (soft); Delete is a
separate action on archived threads that hard-deletes the row. Aligns
with Twenty's soft-delete convention (Felix's suggestion).
- `lastMessageAt` is derived from `MAX(agentMessage.createdAt)` on read,
not stored. List query does inline aggregation for sort; `@ResolveField`
covers single-thread / mutation paths so the schema contract is honest
everywhere. Matches `timeline-messaging.service.ts` precedent and the
existing `totalInputCredits` / `totalOutputCredits` `@ResolveField`
pattern in the same resolver.
- Replaced auto-CRUD `chatThreads` (cursor-paginated Connection) with a
custom `[AgentChatThreadDTO!]` resolver. Frontend metadata-store treats
threads as a flat collection and filters/sorts client-side, so cursor
pagination was performative.
- Sending in an archived chat unarchives it optimistically on the client
and authoritatively on the server.
- Grouping and last-activity filtering use `lastMessageAt ?? updatedAt`
so archive/rename don't bump threads in the list.
- Kept metadata-store core API unchanged; AI chat uses the same local
cast pattern already used by other metadata-store partial updates.


https://github.com/user-attachments/assets/1b179b7b-1a2a-4a7a-aa0a-c88f6f051a87
This commit is contained in:
nitin
2026-04-30 21:12:10 +05:30
committed by GitHub
parent 4b76457217
commit e1828b6f41
111 changed files with 2915 additions and 1139 deletions
@@ -5,7 +5,7 @@ import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessio
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
import { AgentChatThreadInitializationEffect } from '@/ai/components/AgentChatThreadInitializationEffect';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { Suspense } from 'react';
export const AgentChatProviderContent = ({
@@ -1,4 +1,4 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/agentChatIsScrolledToBottomSelector';
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/selectors/agentChatIsScrolledToBottomSelector';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
@@ -8,15 +8,15 @@ import {
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatThreadsSelector } from '@/ai/states/agentChatThreadsSelector';
import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState';
import { agentChatVisibleThreadsSelector } from '@/ai/states/selectors/agentChatVisibleThreadsSelector';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
import { hasInitializedAgentChatThreadsState } from '@/ai/states/hasInitializedAgentChatThreadsState';
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
import { sortChatThreadsByLastActivityDesc } from '@/ai/utils/sortChatThreadsByLastActivityDesc';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
@@ -48,7 +48,9 @@ export const AgentChatThreadInitializationEffect = () => {
agentChatUsageComponentFamilyState,
);
const store = useStore();
const agentChatThreads = useAtomStateValue(agentChatThreadsSelector);
const agentChatVisibleThreads = useAtomStateValue(
agentChatVisibleThreadsSelector,
);
const storeEntry = useAtomValue(
metadataStoreState.atomFamily('agentChatThreads'),
);
@@ -63,17 +65,14 @@ export const AgentChatThreadInitializationEffect = () => {
client
.query({
query: GetChatThreadsDocument,
variables: { paging: { first: 500 } },
fetchPolicy: 'network-only',
})
.then((result) => {
if (!isDefined(result.data?.chatThreads?.edges)) {
if (!isDefined(result.data?.chatThreads)) {
return;
}
const threads = result.data.chatThreads.edges.map((edge) => edge.node);
replaceDraft('agentChatThreads', threads);
replaceDraft('agentChatThreads', result.data.chatThreads);
applyChanges();
});
}, [
@@ -104,9 +103,8 @@ export const AgentChatThreadInitializationEffect = () => {
setHasInitializedAgentChatThreads(true);
const sortedThreads = agentChatThreads.toSorted(
(a: FlatAgentChatThread, b: FlatAgentChatThread) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
const sortedThreads = sortChatThreadsByLastActivityDesc(
agentChatVisibleThreads,
);
if (sortedThreads.length > 0) {
@@ -152,7 +150,7 @@ export const AgentChatThreadInitializationEffect = () => {
);
}
}, [
agentChatThreads,
agentChatVisibleThreads,
currentAiChatThread,
hasAiSettingsPermission,
hasInitializedAgentChatThreads,
@@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { AiChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AiChatSuggestedPrompts';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
@@ -3,8 +3,8 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/selectors/agentChatMessageComponentFamilySelector';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/selectors/agentChatMessageIdsComponentSelector';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -2,7 +2,7 @@ import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
import { agentChatLastMessageIdComponentSelector } from '@/ai/states/agentChatLastMessageIdComponentSelector';
import { agentChatLastMessageIdComponentSelector } from '@/ai/states/selectors/agentChatLastMessageIdComponentSelector';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -5,7 +5,7 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AiChatAssistantMessageRenderer } from '@/ai/components/AiChatAssistantMessageRenderer';
import { AiChatErrorRenderer } from '@/ai/components/AiChatErrorRenderer';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/selectors/agentChatMessageComponentFamilySelector';
import { type AiChatError } from '@/ai/types/AiChatError';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
@@ -1,5 +1,5 @@
import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/agentChatNonLastMessageIdsComponentSelector';
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/selectors/agentChatNonLastMessageIdsComponentSelector';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
export const AiChatNonLastMessageIdsList = () => {
@@ -1,4 +1,4 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/agentChatIsScrolledToBottomSelector';
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/selectors/agentChatIsScrolledToBottomSelector';
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -3,7 +3,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatErrorRenderer } from '@/ai/components/AiChatErrorRenderer';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
@@ -4,7 +4,7 @@ import { AiChatNonLastMessageIdsList } from '@/ai/components/AiChatNonLastMessag
import { AiChatScrollToBottomButton } from '@/ai/components/AiChatScrollToBottomButton';
import { AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect } from '@/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -0,0 +1,54 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
import { useDeleteChatThread } from '@/ai/hooks/useDeleteChatThread';
import { aiChatThreadPendingDeleteFamilyState } from '@/ai/states/aiChatThreadPendingDeleteFamilyState';
import { getAiChatThreadDeleteModalId } from '@/ai/utils/getAiChatThreadDeleteModalId';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
type AiChatThreadDeleteConfirmationModalProps = {
surface: AiChatThreadActionsSurface;
};
export const AiChatThreadDeleteConfirmationModal = ({
surface,
}: AiChatThreadDeleteConfirmationModalProps) => {
const { t } = useLingui();
const { deleteChatThread } = useDeleteChatThread();
const aiChatThreadPendingDelete = useAtomFamilyStateValue(
aiChatThreadPendingDeleteFamilyState,
surface,
);
const setAiChatThreadPendingDelete = useSetAtomFamilyState(
aiChatThreadPendingDeleteFamilyState,
surface,
);
const modalInstanceId = getAiChatThreadDeleteModalId(surface);
const handleDelete = async () => {
if (aiChatThreadPendingDelete === null) return;
await deleteChatThread(aiChatThreadPendingDelete.threadId);
setAiChatThreadPendingDelete(null);
};
return (
<ConfirmationModal
modalInstanceId={modalInstanceId}
title={t`Delete chat`}
subtitle={
<Trans>
<strong>{aiChatThreadPendingDelete?.threadTitle ?? ''}</strong> and
all its messages will be removed.
</Trans>
}
onConfirmClick={handleDelete}
onClose={() => setAiChatThreadPendingDelete(null)}
confirmButtonText={t`Delete`}
confirmButtonAccent="danger"
/>
);
};
@@ -0,0 +1,51 @@
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { IconAdjustments } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AiChatThreadFilterDropdownContent } from '@/ai/components/AiChatThreadFilterDropdownContent';
import { AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE } from '@/ai/constants/AiChatThreadFilterDropdownPage';
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
import { type AiChatThreadFilterDropdownPage } from '@/ai/types/AiChatThreadFilterDropdownPage';
import { getAiChatThreadFilterDropdownId } from '@/ai/utils/getAiChatThreadFilterDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
type AiChatThreadFilterDropdownProps = {
surface: AiChatThreadActionsSurface;
};
export const AiChatThreadFilterDropdown = ({
surface,
}: AiChatThreadFilterDropdownProps) => {
const { t } = useLingui();
const dropdownId = getAiChatThreadFilterDropdownId(surface);
const [page, setPage] = useState<AiChatThreadFilterDropdownPage>(
AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.ROOT,
);
const goToRoot = () => setPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.ROOT);
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
onClose={goToRoot}
clickableComponent={
<LightIconButton
aria-label={t`Filter chats`}
Icon={IconAdjustments}
accent="tertiary"
size="small"
/>
}
dropdownComponents={
<AiChatThreadFilterDropdownContent
page={page}
dropdownId={dropdownId}
onSelectPage={setPage}
onBack={goToRoot}
/>
}
/>
);
};
@@ -0,0 +1,37 @@
import { AiChatThreadFilterDropdownGroupByMenu } from '@/ai/components/AiChatThreadFilterDropdownGroupByMenu';
import { AiChatThreadFilterDropdownLastActivityMenu } from '@/ai/components/AiChatThreadFilterDropdownLastActivityMenu';
import { AiChatThreadFilterDropdownRootMenu } from '@/ai/components/AiChatThreadFilterDropdownRootMenu';
import { AiChatThreadFilterDropdownStatusMenu } from '@/ai/components/AiChatThreadFilterDropdownStatusMenu';
import { AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE } from '@/ai/constants/AiChatThreadFilterDropdownPage';
import { type AiChatThreadFilterDropdownPage } from '@/ai/types/AiChatThreadFilterDropdownPage';
type AiChatThreadFilterDropdownContentProps = {
page: AiChatThreadFilterDropdownPage;
dropdownId: string;
onSelectPage: (page: AiChatThreadFilterDropdownPage) => void;
onBack: () => void;
};
export const AiChatThreadFilterDropdownContent = ({
page,
dropdownId,
onSelectPage,
onBack,
}: AiChatThreadFilterDropdownContentProps) => {
switch (page) {
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.STATUS:
return <AiChatThreadFilterDropdownStatusMenu onBack={onBack} />;
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.GROUP_BY:
return <AiChatThreadFilterDropdownGroupByMenu onBack={onBack} />;
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.LAST_ACTIVITY:
return <AiChatThreadFilterDropdownLastActivityMenu onBack={onBack} />;
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.ROOT:
default:
return (
<AiChatThreadFilterDropdownRootMenu
dropdownId={dropdownId}
onSelectPage={onSelectPage}
/>
);
}
};
@@ -0,0 +1,60 @@
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AGENT_CHAT_THREAD_GROUP_BY_LABELS } from '@/ai/constants/AgentChatThreadGroupByLabels';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const AGENT_CHAT_THREAD_GROUP_BY_OPTIONS = [
AGENT_CHAT_THREAD_GROUP_BY.DATE,
AGENT_CHAT_THREAD_GROUP_BY.NONE,
] as const;
type AiChatThreadFilterDropdownGroupByMenuProps = {
onBack: () => void;
};
export const AiChatThreadFilterDropdownGroupByMenu = ({
onBack,
}: AiChatThreadFilterDropdownGroupByMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [agentChatThreadGroupBy, setAgentChatThreadGroupBy] = useAtomState(
agentChatThreadGroupByState,
);
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Group by`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{AGENT_CHAT_THREAD_GROUP_BY_OPTIONS.map((option) => (
<MenuItemSelect
key={option}
text={t(AGENT_CHAT_THREAD_GROUP_BY_LABELS[option])}
selected={agentChatThreadGroupBy === option}
onClick={() => {
setAgentChatThreadGroupBy(option);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,64 @@
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER } from '@/ai/constants/AgentChatThreadLastActivityFilter';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS } from '@/ai/constants/AgentChatThreadLastActivityFilterLabels';
import { agentChatThreadLastActivityFilterState } from '@/ai/states/agentChatThreadLastActivityFilterState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_OPTIONS = [
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ONE_DAY,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.THREE_DAYS,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.SEVEN_DAYS,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.THIRTY_DAYS,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ALL,
] as const;
type AiChatThreadFilterDropdownLastActivityMenuProps = {
onBack: () => void;
};
export const AiChatThreadFilterDropdownLastActivityMenu = ({
onBack,
}: AiChatThreadFilterDropdownLastActivityMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [
agentChatThreadLastActivityFilter,
setAgentChatThreadLastActivityFilter,
] = useAtomState(agentChatThreadLastActivityFilterState);
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Last activity`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_OPTIONS.map((option) => (
<MenuItemSelect
key={option}
text={t(AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS[option])}
selected={agentChatThreadLastActivityFilter === option}
onClick={() => {
setAgentChatThreadLastActivityFilter(option);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,119 @@
import { useLingui } from '@lingui/react/macro';
import {
IconClock,
IconLayoutList,
IconStatusChange,
IconTrash,
} from 'twenty-ui/display';
import { AGENT_CHAT_THREAD_FILTER_STATUS } from '@/ai/constants/AgentChatThreadFilterStatus';
import { AGENT_CHAT_THREAD_FILTER_STATUS_LABELS } from '@/ai/constants/AgentChatThreadFilterStatusLabels';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AGENT_CHAT_THREAD_GROUP_BY_LABELS } from '@/ai/constants/AgentChatThreadGroupByLabels';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER } from '@/ai/constants/AgentChatThreadLastActivityFilter';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS } from '@/ai/constants/AgentChatThreadLastActivityFilterLabels';
import { AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE } from '@/ai/constants/AiChatThreadFilterDropdownPage';
import { type AiChatThreadFilterDropdownPage } from '@/ai/types/AiChatThreadFilterDropdownPage';
import { agentChatThreadFilterStatusState } from '@/ai/states/agentChatThreadFilterStatusState';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { agentChatThreadLastActivityFilterState } from '@/ai/states/agentChatThreadLastActivityFilterState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { MenuItem } from 'twenty-ui/navigation';
type AiChatThreadFilterDropdownRootMenuProps = {
dropdownId: string;
onSelectPage: (page: AiChatThreadFilterDropdownPage) => void;
};
export const AiChatThreadFilterDropdownRootMenu = ({
dropdownId,
onSelectPage,
}: AiChatThreadFilterDropdownRootMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [agentChatThreadFilterStatus, setAgentChatThreadFilterStatus] =
useAtomState(agentChatThreadFilterStatusState);
const [agentChatThreadGroupBy, setAgentChatThreadGroupBy] = useAtomState(
agentChatThreadGroupByState,
);
const [
agentChatThreadLastActivityFilter,
setAgentChatThreadLastActivityFilter,
] = useAtomState(agentChatThreadLastActivityFilterState);
const isAtDefaults =
agentChatThreadFilterStatus === AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE &&
agentChatThreadGroupBy === AGENT_CHAT_THREAD_GROUP_BY.DATE &&
agentChatThreadLastActivityFilter ===
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ALL;
const handleClearFilters = () => {
setAgentChatThreadFilterStatus(AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE);
setAgentChatThreadGroupBy(AGENT_CHAT_THREAD_GROUP_BY.DATE);
setAgentChatThreadLastActivityFilter(
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ALL,
);
closeDropdown(dropdownId);
};
return (
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconStatusChange}
text={t`Status`}
contextualText={t(
AGENT_CHAT_THREAD_FILTER_STATUS_LABELS[agentChatThreadFilterStatus],
)}
contextualTextPosition="right"
hasSubMenu
onClick={() =>
onSelectPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.STATUS)
}
/>
<MenuItem
LeftIcon={IconLayoutList}
text={t`Group by`}
contextualText={t(
AGENT_CHAT_THREAD_GROUP_BY_LABELS[agentChatThreadGroupBy],
)}
contextualTextPosition="right"
hasSubMenu
onClick={() =>
onSelectPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.GROUP_BY)
}
/>
<MenuItem
LeftIcon={IconClock}
text={t`Last activity`}
contextualText={t(
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS[
agentChatThreadLastActivityFilter
],
)}
contextualTextPosition="right"
hasSubMenu
onClick={() =>
onSelectPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.LAST_ACTIVITY)
}
/>
{!isAtDefaults && (
<>
<DropdownMenuSeparator />
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text={t`Clear filters`}
onClick={handleClearFilters}
/>
</>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,60 @@
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { AGENT_CHAT_THREAD_FILTER_STATUS } from '@/ai/constants/AgentChatThreadFilterStatus';
import { AGENT_CHAT_THREAD_FILTER_STATUS_LABELS } from '@/ai/constants/AgentChatThreadFilterStatusLabels';
import { agentChatThreadFilterStatusState } from '@/ai/states/agentChatThreadFilterStatusState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const AGENT_CHAT_THREAD_FILTER_STATUS_OPTIONS = [
AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE,
AGENT_CHAT_THREAD_FILTER_STATUS.ARCHIVED,
AGENT_CHAT_THREAD_FILTER_STATUS.ALL,
] as const;
type AiChatThreadFilterDropdownStatusMenuProps = {
onBack: () => void;
};
export const AiChatThreadFilterDropdownStatusMenu = ({
onBack,
}: AiChatThreadFilterDropdownStatusMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [agentChatThreadFilterStatus, setAgentChatThreadFilterStatus] =
useAtomState(agentChatThreadFilterStatusState);
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Status`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{AGENT_CHAT_THREAD_FILTER_STATUS_OPTIONS.map((option) => (
<MenuItemSelect
key={option}
text={t(AGENT_CHAT_THREAD_FILTER_STATUS_LABELS[option])}
selected={agentChatThreadFilterStatus === option}
onClick={() => {
setAgentChatThreadFilterStatus(option);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -1,9 +1,9 @@
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { IconSparkles } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadListItem } from '@/ai/components/AiChatThreadListItem';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { type AgentChatThread } from '~/generated-metadata/graphql';
const StyledThreadsList = styled.div`
@@ -16,91 +16,33 @@ const StyledDateGroup = styled.div`
margin-bottom: ${themeCssVariables.spacing[4]};
`;
const StyledDateHeader = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.medium};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledThreadItem = styled.div<{ isSelected?: boolean }>`
align-items: center;
border-left: 3px solid transparent;
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} 1px;
position: relative;
right: 3px;
transition: all 0.2s ease;
width: calc(100% + 1px);
&:hover {
background: ${themeCssVariables.background.transparent.light};
}
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${themeCssVariables.background.transparent.blue};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
justify-content: center;
padding: ${themeCssVariables.spacing[1]};
`;
const StyledThreadContent = styled.div`
flex: 1;
min-width: 0;
`;
const StyledThreadTitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const AiChatThreadGroup = ({
threads,
title,
}: {
type AiChatThreadGroupProps = {
alwaysShowRightIcon?: boolean;
rightIcon?: ReactNode;
threads: AgentChatThread[];
title: string;
}) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const { handleThreadClick } = useAiChatThreadClick();
};
export const AiChatThreadGroup = ({
alwaysShowRightIcon = false,
rightIcon,
threads,
title,
}: AiChatThreadGroupProps) => {
if (threads.length === 0) {
return null;
}
return (
<StyledDateGroup>
<StyledDateHeader>{title}</StyledDateHeader>
<NavigationDrawerSectionTitle
label={title}
alwaysShowRightIcon={alwaysShowRightIcon}
rightIcon={rightIcon}
/>
<StyledThreadsList>
{threads.map((thread) => (
<StyledThreadItem
onClick={() => handleThreadClick(thread)}
key={thread.id}
>
<StyledSparkleIcon>
<IconSparkles
size={theme.icon.size.md}
color={theme.color.blue}
/>
</StyledSparkleIcon>
<StyledThreadContent>
<StyledThreadTitle>
{thread.title || t`Untitled`}
</StyledThreadTitle>
</StyledThreadContent>
</StyledThreadItem>
<AiChatThreadListItem key={thread.id} thread={thread} />
))}
</StyledThreadsList>
</StyledDateGroup>
@@ -0,0 +1,108 @@
import { useLingui } from '@lingui/react/macro';
import {
IconArchive,
IconArchiveOff,
IconDotsVertical,
IconPencil,
IconTrash,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
import { useChatThreadArchiveActions } from '@/ai/hooks/useChatThreadArchiveActions';
import { aiChatThreadPendingDeleteFamilyState } from '@/ai/states/aiChatThreadPendingDeleteFamilyState';
import { getAiChatThreadDeleteModalId } from '@/ai/utils/getAiChatThreadDeleteModalId';
import { getAiChatThreadItemMenuDropdownId } from '@/ai/utils/getAiChatThreadItemMenuDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
type AiChatThreadItemMenuProps = {
threadId: string;
threadTitle: string;
isArchived: boolean;
surface: AiChatThreadActionsSurface;
onRenameRequested: () => void;
};
export const AiChatThreadItemMenu = ({
threadId,
threadTitle,
isArchived,
surface,
onRenameRequested,
}: AiChatThreadItemMenuProps) => {
const { t } = useLingui();
const dropdownId = getAiChatThreadItemMenuDropdownId(threadId, surface);
const { closeDropdown } = useCloseDropdown();
const { openModal } = useModal();
const { archiveChatThread, unarchiveChatThread } =
useChatThreadArchiveActions();
const setAiChatThreadPendingDelete = useSetAtomFamilyState(
aiChatThreadPendingDeleteFamilyState,
surface,
);
const handleRename = (event: React.MouseEvent) => {
event.stopPropagation();
closeDropdown(dropdownId);
onRenameRequested();
};
const handleArchive = async (event: React.MouseEvent) => {
event.stopPropagation();
closeDropdown(dropdownId);
if (isArchived) {
await unarchiveChatThread(threadId);
} else {
await archiveChatThread(threadId);
}
};
const handleDelete = (event: React.MouseEvent) => {
event.stopPropagation();
closeDropdown(dropdownId);
setAiChatThreadPendingDelete({ threadId, threadTitle });
openModal(getAiChatThreadDeleteModalId(surface));
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
clickableComponent={
<LightIconButton
aria-label={t`Chat actions`}
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Rename`}
LeftIcon={IconPencil}
onClick={handleRename}
/>
<MenuItem
text={isArchived ? t`Unarchive` : t`Archive`}
LeftIcon={isArchived ? IconArchiveOff : IconArchive}
onClick={handleArchive}
/>
<MenuItem
accent="danger"
text={t`Delete`}
LeftIcon={IconTrash}
onClick={handleDelete}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,163 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { Key } from 'ts-key-enum';
import { IconArchive, IconSparkles } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadItemMenu } from '@/ai/components/AiChatThreadItemMenu';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
import { useAiChatThreadRename } from '@/ai/hooks/useAiChatThreadRename';
import { getAiChatThreadItemMenuDropdownId } from '@/ai/utils/getAiChatThreadItemMenuDropdownId';
import { TextInput } from '@/ui/input/components/TextInput';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type AgentChatThread } from '~/generated-metadata/graphql';
const StyledThreadItem = styled.div`
align-items: center;
border-left: 3px solid transparent;
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} 1px;
position: relative;
right: 3px;
transition: all 0.2s ease;
width: calc(100% + 1px);
&:hover {
background: ${themeCssVariables.background.transparent.light};
}
`;
const StyledThreadIcon = styled.div<{ $isArchived: boolean }>`
align-items: center;
background: ${({ $isArchived }) =>
$isArchived
? themeCssVariables.background.transparent.lighter
: themeCssVariables.background.transparent.blue};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${({ $isArchived }) =>
$isArchived
? themeCssVariables.font.color.tertiary
: themeCssVariables.color.blue};
display: flex;
justify-content: center;
padding: ${themeCssVariables.spacing[1]};
`;
const StyledThreadContent = styled.div`
flex: 1;
min-width: 0;
`;
const StyledThreadTitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledMenuTrigger = styled.div<{ $isDropdownOpen: boolean }>`
opacity: ${({ $isDropdownOpen }) => ($isDropdownOpen ? 1 : 0)};
pointer-events: ${({ $isDropdownOpen }) =>
$isDropdownOpen ? 'auto' : 'none'};
position: absolute;
right: ${themeCssVariables.spacing[1]};
top: 50%;
transform: translateY(-50%);
transition: opacity 150ms;
${StyledThreadItem}:hover & {
opacity: 1;
pointer-events: auto;
}
`;
type AiChatThreadListItemProps = {
thread: AgentChatThread;
};
export const AiChatThreadListItem = ({ thread }: AiChatThreadListItemProps) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const { handleThreadClick } = useAiChatThreadClick();
const {
isRenaming,
draftTitle,
setDraftTitle,
startRename,
cancelRename,
commitRename,
} = useAiChatThreadRename(thread);
const isArchived = Boolean(thread.deletedAt);
const ThreadIcon = isArchived ? IconArchive : IconSparkles;
const displayTitle = thread.title ?? t`Untitled`;
const itemMenuDropdownId = getAiChatThreadItemMenuDropdownId(
thread.id,
AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL,
);
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
itemMenuDropdownId,
);
return (
<StyledThreadItem
onClick={() => {
if (!isRenaming) {
handleThreadClick(thread);
}
}}
>
<StyledThreadIcon $isArchived={isArchived}>
<ThreadIcon size={theme.icon.size.md} color="currentColor" />
</StyledThreadIcon>
<StyledThreadContent>
{isRenaming ? (
<TextInput
value={draftTitle}
onChange={setDraftTitle}
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.target.select()}
onBlur={() => commitRename(draftTitle)}
onKeyDown={(event) => {
if (event.key === Key.Enter) {
event.preventDefault();
void commitRename(draftTitle);
} else if (event.key === Key.Escape) {
event.preventDefault();
cancelRename();
}
}}
sizeVariant="sm"
fullWidth
autoFocus
aria-label={t`Rename chat`}
/>
) : (
<StyledThreadTitle>{displayTitle}</StyledThreadTitle>
)}
</StyledThreadContent>
<StyledMenuTrigger
$isDropdownOpen={isDropdownOpen}
onClick={(event) => event.stopPropagation()}
>
<AiChatThreadItemMenu
threadId={thread.id}
threadTitle={displayTitle}
isArchived={isArchived}
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL}
onRenameRequested={startRename}
/>
</StyledMenuTrigger>
</StyledThreadItem>
);
};
@@ -1,15 +1,22 @@
import { styled } from '@linaria/react';
import { AiChatThreadDeleteConfirmationModal } from '@/ai/components/AiChatThreadDeleteConfirmationModal';
import { AiChatThreadFilterDropdown } from '@/ai/components/AiChatThreadFilterDropdown';
import { AiChatThreadGroup } from '@/ai/components/AiChatThreadGroup';
import { AiChatThreadListItem } from '@/ai/components/AiChatThreadListItem';
import { AiChatThreadsListFocusEffect } from '@/ai/components/AiChatThreadsListFocusEffect';
import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useChatThreads } from '@/ai/hooks/useChatThreads';
import { useSwitchToNewAiChat } from '@/ai/hooks/useSwitchToNewAiChat';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { Key } from 'ts-key-enum';
import { capitalize } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getOsControlSymbol } from 'twenty-ui/utilities';
@@ -28,11 +35,17 @@ const StyledThreadsContainer = styled.div`
padding: ${themeCssVariables.spacing[3]};
`;
const StyledFlatThreadList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledButtonsContainer = styled.div`
border-top: 1px solid ${themeCssVariables.border.color.medium};
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]} 10px;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]};
`;
export const AiChatThreadsList = () => {
@@ -48,25 +61,51 @@ export const AiChatThreadsList = () => {
});
const { threads, hasNextPage, loading, fetchMoreRef } = useChatThreads();
const groupedThreads = groupThreadsByDate(threads);
const agentChatThreadGroupBy = useAtomStateValue(agentChatThreadGroupByState);
if (loading && threads.length === 0) {
return <AiChatSkeletonLoader />;
}
const isGroupedByDate =
agentChatThreadGroupBy === AGENT_CHAT_THREAD_GROUP_BY.DATE;
const dateGroups = isGroupedByDate ? groupThreadsByDate(threads) : [];
const shouldRenderDateGroups = isGroupedByDate && dateGroups.length > 0;
const filterDropdown = (
<AiChatThreadFilterDropdown
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL}
/>
);
return (
<>
<AiChatThreadsListFocusEffect focusId={focusId} />
<StyledContainer>
<StyledThreadsContainer>
{Object.entries(groupedThreads).map(([title, threadsInGroup]) => (
<AiChatThreadGroup
key={title}
title={capitalize(title)}
threads={threadsInGroup}
/>
))}
{shouldRenderDateGroups ? (
dateGroups.map((dateGroup, index) => (
<AiChatThreadGroup
key={dateGroup.id}
title={dateGroup.title}
threads={dateGroup.threads}
rightIcon={index === 0 ? filterDropdown : undefined}
alwaysShowRightIcon={index === 0}
/>
))
) : (
<>
<NavigationDrawerSectionTitle
label={t`Recents`}
alwaysShowRightIcon
rightIcon={filterDropdown}
/>
<StyledFlatThreadList>
{threads.map((thread) => (
<AiChatThreadListItem key={thread.id} thread={thread} />
))}
</StyledFlatThreadList>
</>
)}
{hasNextPage ? (
<div ref={fetchMoreRef} style={{ minHeight: 1 }} />
) : null}
@@ -82,6 +121,9 @@ export const AiChatThreadsList = () => {
/>
</StyledButtonsContainer>
</StyledContainer>
<AiChatThreadDeleteConfirmationModal
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL}
/>
</>
);
};
@@ -2,14 +2,16 @@ import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadDeleteConfirmationModal } from '@/ai/components/AiChatThreadDeleteConfirmationModal';
import { AiChatThreadFilterDropdown } from '@/ai/components/AiChatThreadFilterDropdown';
import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader';
import { NavigationDrawerAiChatThreadDateSection } from '@/ai/components/NavigationDrawerAiChatThreadDateSection';
import { NavigationDrawerAiChatThreadSection } from '@/ai/components/NavigationDrawerAiChatThreadSection';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
import { useChatThreads } from '@/ai/hooks/useChatThreads';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
import { DATE_GROUP_KEYS } from '@/ai/utils/dateGroupKeys';
import { getDateGroupTitle } from '@/ai/utils/getDateGroupTitle';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -22,11 +24,19 @@ const StyledContainer = styled.div`
const StyledThreadList = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[0]};
width: calc(100% - ${themeCssVariables.spacing[2]});
`;
const StyledSectionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
const StyledEmptyState = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.light};
@@ -42,6 +52,8 @@ const StyledFetchMoreTrigger = styled.div`
width: 100%;
`;
const AI_CHAT_RECENTS_NAVIGATION_SECTION_ID = 'AiChatRecents';
export const NavigationDrawerAiChatContent = () => {
const { t } = useLingui();
@@ -49,11 +61,10 @@ export const NavigationDrawerAiChatContent = () => {
const { handleThreadClick } = useAiChatThreadClick({
resetNavigationStack: true,
});
const agentChatThreadGroupBy = useAtomStateValue(agentChatThreadGroupByState);
const { threads, hasNextPage, loading, fetchMoreRef } = useChatThreads();
const groupedThreads = groupThreadsByDate(threads);
if (loading && threads.length === 0) {
return (
<StyledContainer>
@@ -62,33 +73,54 @@ export const NavigationDrawerAiChatContent = () => {
);
}
if (threads.length === 0) {
return (
<StyledContainer>
<StyledEmptyState>{t`No chat`}</StyledEmptyState>
</StyledContainer>
);
}
const isGroupedByDate =
agentChatThreadGroupBy === AGENT_CHAT_THREAD_GROUP_BY.DATE;
const dateGroups = isGroupedByDate ? groupThreadsByDate(threads) : [];
const shouldRenderDateGroups = isGroupedByDate && dateGroups.length > 0;
const filterDropdown = (
<AiChatThreadFilterDropdown
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER}
/>
);
return (
<StyledContainer>
<StyledThreadList>
{DATE_GROUP_KEYS.map((key: DateGroupKey) => {
const threadsInGroup = groupedThreads[key];
if (threadsInGroup.length === 0) return null;
return (
<NavigationDrawerAiChatThreadDateSection
key={key}
title={getDateGroupTitle(key)}
threads={threadsInGroup}
currentThreadId={currentAiChatThread}
onThreadClick={handleThreadClick}
/>
);
})}
{shouldRenderDateGroups ? (
<StyledSectionsContainer>
{dateGroups.map((dateGroup, index) => (
<NavigationDrawerAiChatThreadSection
key={dateGroup.id}
sectionId={`AiChatDateGroup:${dateGroup.id}`}
title={dateGroup.title}
threads={dateGroup.threads}
currentThreadId={currentAiChatThread}
onThreadClick={handleThreadClick}
rightIcon={index === 0 ? filterDropdown : undefined}
alwaysShowRightIcon={index === 0}
/>
))}
</StyledSectionsContainer>
) : (
<NavigationDrawerAiChatThreadSection
sectionId={AI_CHAT_RECENTS_NAVIGATION_SECTION_ID}
title={t`Recents`}
threads={threads}
currentThreadId={currentAiChatThread}
onThreadClick={handleThreadClick}
rightIcon={filterDropdown}
alwaysShowRightIcon
/>
)}
{threads.length === 0 ? (
<StyledEmptyState>{t`No chat`}</StyledEmptyState>
) : null}
{hasNextPage ? <StyledFetchMoreTrigger ref={fetchMoreRef} /> : null}
</StyledThreadList>
<AiChatThreadDeleteConfirmationModal
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER}
/>
</StyledContainer>
);
};
@@ -1,76 +0,0 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconComment } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { type AgentChatThread } from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNowShort } from '~/utils/date-utils';
const StyledDateSection = styled.section`
margin-bottom: ${themeCssVariables.spacing[4]};
`;
const StyledThreadList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledDateHeader = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.medium};
margin-bottom: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
const StyledThreadTimestamp = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.regular};
padding-right: ${themeCssVariables.spacing['0.5']};
`;
export type NavigationDrawerAiChatThreadDateSectionProps = {
title: string;
threads: AgentChatThread[];
currentThreadId: string | null;
onThreadClick: (thread: AgentChatThread) => void;
};
export const NavigationDrawerAiChatThreadDateSection = ({
title,
threads,
currentThreadId,
onThreadClick,
}: NavigationDrawerAiChatThreadDateSectionProps) => {
const { t } = useLingui();
return (
<StyledDateSection>
<StyledDateHeader>{title}</StyledDateHeader>
<StyledThreadList>
{threads.map((thread) => {
const isActive = currentThreadId === thread.id;
const timestamp = beautifyPastDateRelativeToNowShort(
thread.updatedAt ?? thread.createdAt,
);
return (
<NavigationDrawerItem
key={thread.id}
label={thread.title || t`New chat`}
Icon={IconComment}
active={isActive}
onClick={() => onThreadClick(thread)}
alwaysShowRightOptions
rightOptions={
<StyledThreadTimestamp>{timestamp}</StyledThreadTimestamp>
}
/>
);
})}
</StyledThreadList>
</StyledDateSection>
);
};
@@ -0,0 +1,129 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconArchive, IconComment } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadItemMenu } from '@/ai/components/AiChatThreadItemMenu';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useAiChatThreadRename } from '@/ai/hooks/useAiChatThreadRename';
import { getAiChatThreadItemMenuDropdownId } from '@/ai/utils/getAiChatThreadItemMenuDropdownId';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type AgentChatThread } from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNowShort } from '~/utils/date-utils';
const StyledRightOptions = styled.div`
align-items: center;
display: flex;
height: ${themeCssVariables.spacing[6]};
justify-content: flex-end;
min-width: ${themeCssVariables.spacing[6]};
position: relative;
`;
const StyledTimestamp = styled.span<{ $isDropdownOpen: boolean }>`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.regular};
opacity: ${({ $isDropdownOpen }) => ($isDropdownOpen ? 0 : 1)};
transition: opacity 150ms;
.navigation-drawer-item:hover & {
opacity: 0;
}
`;
const StyledMenuTrigger = styled.div<{ $isDropdownOpen: boolean }>`
opacity: ${({ $isDropdownOpen }) => ($isDropdownOpen ? 1 : 0)};
pointer-events: ${({ $isDropdownOpen }) =>
$isDropdownOpen ? 'auto' : 'none'};
position: absolute;
right: 0;
top: 0;
transition: opacity 150ms;
.navigation-drawer-item:hover & {
opacity: 1;
pointer-events: auto;
}
`;
type NavigationDrawerAiChatThreadItemProps = {
thread: AgentChatThread;
isActive: boolean;
onClick: (thread: AgentChatThread) => void;
};
export const NavigationDrawerAiChatThreadItem = ({
thread,
isActive,
onClick,
}: NavigationDrawerAiChatThreadItemProps) => {
const { t } = useLingui();
const {
isRenaming,
draftTitle,
setDraftTitle,
startRename,
cancelRename,
commitRename,
} = useAiChatThreadRename(thread);
const isArchived = Boolean(thread.deletedAt);
const ThreadIcon = isArchived ? IconArchive : IconComment;
const displayLabel = thread.title || t`New chat`;
const timestamp = beautifyPastDateRelativeToNowShort(
thread.lastMessageAt ?? thread.updatedAt ?? thread.createdAt,
);
const itemMenuDropdownId = getAiChatThreadItemMenuDropdownId(
thread.id,
AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER,
);
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
itemMenuDropdownId,
);
if (isRenaming) {
return (
<NavigationDrawerInput
Icon={ThreadIcon}
value={draftTitle}
onChange={setDraftTitle}
onSubmit={commitRename}
onCancel={cancelRename}
onClickOutside={(_event, value) => commitRename(value)}
placeholder={t`Chat name`}
/>
);
}
return (
<NavigationDrawerItem
label={displayLabel}
Icon={ThreadIcon}
active={isActive}
onClick={() => onClick(thread)}
variant={isArchived ? 'tertiary' : 'default'}
alwaysShowRightOptions
rightOptions={
<StyledRightOptions>
<StyledTimestamp $isDropdownOpen={isDropdownOpen}>
{timestamp}
</StyledTimestamp>
<StyledMenuTrigger $isDropdownOpen={isDropdownOpen}>
<AiChatThreadItemMenu
threadId={thread.id}
threadTitle={displayLabel}
isArchived={isArchived}
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER}
onRenameRequested={startRename}
/>
</StyledMenuTrigger>
</StyledRightOptions>
}
/>
);
};
@@ -0,0 +1,79 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationDrawerAiChatThreadItem } from '@/ai/components/NavigationDrawerAiChatThreadItem';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { type AgentChatThread } from '~/generated-metadata/graphql';
const StyledSection = styled.section`
display: flex;
flex-direction: column;
`;
const StyledThreadList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
padding-top: ${themeCssVariables.betweenSiblingsGap};
`;
export type NavigationDrawerAiChatThreadSectionProps = {
sectionId: string;
title: string;
threads: AgentChatThread[];
currentThreadId: string | null;
onThreadClick: (thread: AgentChatThread) => void;
rightIcon?: ReactNode;
alwaysShowRightIcon?: boolean;
};
export const NavigationDrawerAiChatThreadSection = ({
sectionId,
title,
threads,
currentThreadId,
onThreadClick,
rightIcon,
alwaysShowRightIcon = false,
}: NavigationDrawerAiChatThreadSectionProps) => {
const { isNavigationSectionOpen, toggleNavigationSection } =
useNavigationSection(sectionId);
return (
<StyledSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={title}
onClick={toggleNavigationSection}
alwaysShowRightIcon={alwaysShowRightIcon}
isOpen={isNavigationSectionOpen}
rightIcon={rightIcon}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{threads.length > 0 ? (
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<StyledThreadList>
{threads.map((thread) => (
<NavigationDrawerAiChatThreadItem
key={thread.id}
thread={thread}
isActive={currentThreadId === thread.id}
onClick={onThreadClick}
/>
))}
</StyledThreadList>
</AnimatedExpandableContainer>
) : null}
</StyledSection>
);
};
@@ -10,7 +10,7 @@ import { ComponentDecorator } from 'twenty-ui/testing';
import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
@@ -8,7 +8,7 @@ import { ProgressBar } from 'twenty-ui/feedback';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import {
agentChatUsageComponentFamilyState,
type AgentChatLastMessageUsage,
@@ -6,7 +6,7 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -1,5 +1,5 @@
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
import { agentChatInputIsEmptySelector } from '@/ai/states/agentChatInputIsEmptySelector';
import { agentChatInputIsEmptySelector } from '@/ai/states/selectors/agentChatInputIsEmptySelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';