Files
twenty/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx
T
nitin e1828b6f41 [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
2026-04-30 15:42:10 +00:00

168 lines
5.9 KiB
TypeScript

import { useAtomValue, useStore } from 'jotai';
import { useEffect } from 'react';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
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 { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useApolloClient } from '@apollo/client/react';
import {
GetChatThreadsDocument,
PermissionFlagType,
} from '~/generated-metadata/graphql';
export const AgentChatThreadInitializationEffect = () => {
const client = useApolloClient();
const { replaceDraft, applyChanges } = useUpdateMetadataStoreDraft();
const hasAiSettingsPermission = useHasPermissionFlag(
PermissionFlagType.AI_SETTINGS,
);
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
const setCurrentAiChatThread = useSetAtomState(currentAiChatThreadState);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const setAgentChatThreadsLoading = useSetAtomState(
agentChatThreadsLoadingState,
);
const threadTitleFamilyCallback = useAtomComponentFamilyStateCallbackState(
currentAiChatThreadTitleComponentFamilyState,
);
const agentChatUsageFamilyCallback = useAtomComponentFamilyStateCallbackState(
agentChatUsageComponentFamilyState,
);
const store = useStore();
const agentChatVisibleThreads = useAtomStateValue(
agentChatVisibleThreadsSelector,
);
const storeEntry = useAtomValue(
metadataStoreState.atomFamily('agentChatThreads'),
);
const [hasInitializedAgentChatThreads, setHasInitializedAgentChatThreads] =
useAtomState(hasInitializedAgentChatThreadsState);
useEffect(() => {
if (storeEntry.status !== 'empty' || !hasAiSettingsPermission) {
return;
}
client
.query({
query: GetChatThreadsDocument,
fetchPolicy: 'network-only',
})
.then((result) => {
if (!isDefined(result.data?.chatThreads)) {
return;
}
replaceDraft('agentChatThreads', result.data.chatThreads);
applyChanges();
});
}, [
storeEntry.status,
hasAiSettingsPermission,
client,
replaceDraft,
applyChanges,
]);
useEffect(() => {
setAgentChatThreadsLoading(
storeEntry.status === 'empty' && hasAiSettingsPermission,
);
}, [storeEntry.status, hasAiSettingsPermission, setAgentChatThreadsLoading]);
useEffect(() => {
if (
hasInitializedAgentChatThreads ||
(currentAiChatThread !== null && isValidUuid(currentAiChatThread))
) {
return;
}
if (storeEntry.status === 'empty' && hasAiSettingsPermission) {
return;
}
setHasInitializedAgentChatThreads(true);
const sortedThreads = sortChatThreadsByLastActivityDesc(
agentChatVisibleThreads,
);
if (sortedThreads.length > 0) {
const firstThread = sortedThreads[0];
const draftForThread =
store.get(agentChatDraftsByThreadIdState.atom)[firstThread.id] ?? '';
setCurrentAiChatThread(firstThread.id);
setAgentChatInput(draftForThread);
const firstThreadFamilyKey = { threadId: firstThread.id };
store.set(
threadTitleFamilyCallback(firstThreadFamilyKey),
firstThread.title ?? null,
);
const hasUsageData =
(firstThread.conversationSize ?? 0) > 0 &&
isDefined(firstThread.contextWindowTokens);
store.set(
agentChatUsageFamilyCallback(firstThreadFamilyKey),
hasUsageData
? {
lastMessage: null,
conversationSize: firstThread.conversationSize ?? 0,
contextWindowTokens: firstThread.contextWindowTokens ?? 0,
inputTokens: firstThread.totalInputTokens,
outputTokens: firstThread.totalOutputTokens,
inputCredits: firstThread.totalInputCredits,
outputCredits: firstThread.totalOutputCredits,
}
: null,
);
} else {
store.set(hasTriggeredCreateForDraftState.atom, false);
setCurrentAiChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
setAgentChatInput(
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
] ?? '',
);
}
}, [
agentChatVisibleThreads,
currentAiChatThread,
hasAiSettingsPermission,
hasInitializedAgentChatThreads,
setHasInitializedAgentChatThreads,
storeEntry.status,
setCurrentAiChatThread,
setAgentChatInput,
store,
threadTitleFamilyCallback,
agentChatUsageFamilyCallback,
]);
return null;
};