e1828b6f41
## 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
168 lines
5.9 KiB
TypeScript
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;
|
|
};
|