Files
twenty/packages/twenty-front/src/modules/ai/utils/groupThreadsByDate.ts
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

84 lines
2.0 KiB
TypeScript

import { t } from '@lingui/core/macro';
import { differenceInCalendarDays } from 'date-fns';
import { type AgentChatThread } from '~/generated-metadata/graphql';
export type AgentChatThreadDateGroup = {
id: string;
title: string;
threads: AgentChatThread[];
};
const getLocalDayDifference = (date: Date, today: Date) =>
differenceInCalendarDays(today, date);
const getMonthGroupId = (date: Date) =>
`month:${date.getFullYear()}-${date.getMonth() + 1}`;
const formatMonthGroupTitle = (date: Date) =>
new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
}).format(date);
const getThreadDateGroup = (
threadActivityAt: Date,
today: Date,
): Omit<AgentChatThreadDateGroup, 'threads'> => {
const localDayDifference = getLocalDayDifference(threadActivityAt, today);
if (localDayDifference === 0) {
return {
id: 'today',
title: t`Today`,
};
}
if (localDayDifference === 1) {
return {
id: 'yesterday',
title: t`Yesterday`,
};
}
if (localDayDifference >= 2 && localDayDifference <= 7) {
return {
id: 'previous-7-days',
title: t`Previous 7 days`,
};
}
return {
id: getMonthGroupId(threadActivityAt),
title: formatMonthGroupTitle(threadActivityAt),
};
};
export const groupThreadsByDate = (
threads: AgentChatThread[],
today = new Date(),
): AgentChatThreadDateGroup[] => {
const groupedThreadsByDate = new Map<string, AgentChatThreadDateGroup>();
for (const thread of threads) {
const threadDateGroup = getThreadDateGroup(
new Date(thread.lastMessageAt ?? thread.updatedAt),
today,
);
const existingThreadDateGroup = groupedThreadsByDate.get(
threadDateGroup.id,
);
if (existingThreadDateGroup !== undefined) {
existingThreadDateGroup.threads.push(thread);
} else {
groupedThreadsByDate.set(threadDateGroup.id, {
...threadDateGroup,
threads: [thread],
});
}
}
return [...groupedThreadsByDate.values()];
};