[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:
@@ -1,29 +1,83 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { differenceInCalendarDays } from 'date-fns';
|
||||
|
||||
import { type AgentChatThread } from '~/generated-metadata/graphql';
|
||||
|
||||
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
|
||||
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[],
|
||||
): Record<DateGroupKey, AgentChatThread[]> => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
today = new Date(),
|
||||
): AgentChatThreadDateGroup[] => {
|
||||
const groupedThreadsByDate = new Map<string, AgentChatThreadDateGroup>();
|
||||
|
||||
return threads.reduce<Record<DateGroupKey, AgentChatThread[]>>(
|
||||
(acc, thread) => {
|
||||
const threadDate = new Date(thread.updatedAt);
|
||||
const threadDateString = threadDate.toDateString();
|
||||
for (const thread of threads) {
|
||||
const threadDateGroup = getThreadDateGroup(
|
||||
new Date(thread.lastMessageAt ?? thread.updatedAt),
|
||||
today,
|
||||
);
|
||||
const existingThreadDateGroup = groupedThreadsByDate.get(
|
||||
threadDateGroup.id,
|
||||
);
|
||||
|
||||
if (threadDateString === today.toDateString()) {
|
||||
acc.today.push(thread);
|
||||
} else if (threadDateString === yesterday.toDateString()) {
|
||||
acc.yesterday.push(thread);
|
||||
} else {
|
||||
acc.older.push(thread);
|
||||
}
|
||||
if (existingThreadDateGroup !== undefined) {
|
||||
existingThreadDateGroup.threads.push(thread);
|
||||
} else {
|
||||
groupedThreadsByDate.set(threadDateGroup.id, {
|
||||
...threadDateGroup,
|
||||
threads: [thread],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ today: [], yesterday: [], older: [] },
|
||||
);
|
||||
return [...groupedThreadsByDate.values()];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user