[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:
@@ -2,13 +2,25 @@ import { type AgentChatThread } from '~/generated-metadata/graphql';
|
||||
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
|
||||
|
||||
describe('groupThreadsByDate', () => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const twoDaysAgo = new Date(today);
|
||||
twoDaysAgo.setDate(today.getDate() - 2);
|
||||
const today = new Date('2026-04-26T12:00:00');
|
||||
|
||||
const baseThread: Omit<AgentChatThread, 'updatedAt' | 'id'> = {
|
||||
const getDateDaysAgo = (daysAgo: number) => {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - daysAgo);
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
const yesterday = getDateDaysAgo(1);
|
||||
const twoDaysAgo = getDateDaysAgo(2);
|
||||
const sevenDaysAgo = getDateDaysAgo(7);
|
||||
const eightDaysAgo = getDateDaysAgo(8);
|
||||
const fourteenDaysAgo = getDateDaysAgo(14);
|
||||
|
||||
const baseThread: Omit<
|
||||
AgentChatThread,
|
||||
'updatedAt' | 'id' | 'lastMessageAt'
|
||||
> = {
|
||||
title: 'Test Thread',
|
||||
createdAt: twoDaysAgo.toISOString(),
|
||||
totalInputTokens: 0,
|
||||
@@ -19,26 +31,107 @@ describe('groupThreadsByDate', () => {
|
||||
totalOutputCredits: 0,
|
||||
};
|
||||
|
||||
const threads: AgentChatThread[] = [
|
||||
{ ...baseThread, id: '1', updatedAt: today.toISOString() },
|
||||
{ ...baseThread, id: '2', updatedAt: yesterday.toISOString() },
|
||||
{ ...baseThread, id: '3', updatedAt: twoDaysAgo.toISOString() },
|
||||
];
|
||||
|
||||
it('groups threads into today, yesterday, and older', () => {
|
||||
const result = groupThreadsByDate(threads);
|
||||
expect(result.today).toHaveLength(1);
|
||||
expect(result.today[0].id).toBe('1');
|
||||
expect(result.yesterday).toHaveLength(1);
|
||||
expect(result.yesterday[0].id).toBe('2');
|
||||
expect(result.older).toHaveLength(1);
|
||||
expect(result.older[0].id).toBe('3');
|
||||
const buildThread = (id: string, lastMessageAt: Date): AgentChatThread => ({
|
||||
...baseThread,
|
||||
id,
|
||||
lastMessageAt: lastMessageAt.toISOString(),
|
||||
updatedAt: lastMessageAt.toISOString(),
|
||||
});
|
||||
|
||||
it('returns empty arrays if no threads', () => {
|
||||
const result = groupThreadsByDate([]);
|
||||
expect(result.today).toEqual([]);
|
||||
expect(result.yesterday).toEqual([]);
|
||||
expect(result.older).toEqual([]);
|
||||
it('groups threads into Today, Yesterday, Previous 7 days, and month sections', () => {
|
||||
const monthFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const threads: AgentChatThread[] = [
|
||||
buildThread('1', today),
|
||||
buildThread('2', yesterday),
|
||||
buildThread('3', twoDaysAgo),
|
||||
buildThread('4', sevenDaysAgo),
|
||||
buildThread('5', eightDaysAgo),
|
||||
buildThread('6', fourteenDaysAgo),
|
||||
];
|
||||
|
||||
const result = groupThreadsByDate(threads, today);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'today',
|
||||
title: 'Today',
|
||||
threads: [{ id: '1' }],
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
id: 'yesterday',
|
||||
title: 'Yesterday',
|
||||
threads: [{ id: '2' }],
|
||||
});
|
||||
expect(result[2]).toMatchObject({
|
||||
id: 'previous-7-days',
|
||||
title: 'Previous 7 days',
|
||||
threads: [{ id: '3' }, { id: '4' }],
|
||||
});
|
||||
expect(result[3]).toMatchObject({
|
||||
id: 'month:2026-4',
|
||||
title: monthFormatter.format(eightDaysAgo),
|
||||
threads: [{ id: '5' }, { id: '6' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no groups if no threads', () => {
|
||||
expect(groupThreadsByDate([], today)).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to updatedAt when lastMessageAt is null', () => {
|
||||
const thread: AgentChatThread = {
|
||||
...baseThread,
|
||||
id: '1',
|
||||
lastMessageAt: null,
|
||||
updatedAt: yesterday.toISOString(),
|
||||
};
|
||||
|
||||
const [group] = groupThreadsByDate([thread], today);
|
||||
|
||||
expect(group.id).toBe('yesterday');
|
||||
});
|
||||
|
||||
describe('timezone handling', () => {
|
||||
it('keeps a thread last touched a few hours ago in Today across DST/midnight boundaries', () => {
|
||||
const todayLate = new Date('2026-04-26T23:30:00');
|
||||
const todayEarly = new Date('2026-04-26T00:15:00');
|
||||
|
||||
const result = groupThreadsByDate(
|
||||
[buildThread('late', todayLate), buildThread('early', todayEarly)],
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'today',
|
||||
threads: [{ id: 'late' }, { id: 'early' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('places a thread last touched late yesterday in the Yesterday bucket', () => {
|
||||
const yesterdayJustBeforeMidnight = new Date('2026-04-25T23:59:00');
|
||||
|
||||
const [group] = groupThreadsByDate(
|
||||
[buildThread('y', yesterdayJustBeforeMidnight)],
|
||||
today,
|
||||
);
|
||||
|
||||
expect(group.id).toBe('yesterday');
|
||||
});
|
||||
|
||||
it('does not slip a thread from yesterday into Today when local times differ by hours', () => {
|
||||
const yesterdayMorning = new Date('2026-04-25T08:00:00');
|
||||
|
||||
const [group] = groupThreadsByDate(
|
||||
[buildThread('y', yesterdayMorning)],
|
||||
today,
|
||||
);
|
||||
|
||||
expect(group.id).toBe('yesterday');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type DateGroupKey = 'today' | 'yesterday' | 'older';
|
||||
@@ -1,7 +0,0 @@
|
||||
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
|
||||
|
||||
export const DATE_GROUP_KEYS: readonly DateGroupKey[] = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'older',
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
|
||||
|
||||
export const getAiChatThreadDeleteModalId = (
|
||||
surface: AiChatThreadActionsSurface,
|
||||
) => `delete-chat-thread-modal-${surface}`;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
|
||||
|
||||
export const getAiChatThreadFilterDropdownId = (
|
||||
surface: AiChatThreadActionsSurface,
|
||||
) => `ai-chat-thread-filter-${surface}`;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
|
||||
|
||||
export const getAiChatThreadItemMenuDropdownId = (
|
||||
threadId: string,
|
||||
surface: AiChatThreadActionsSurface,
|
||||
) => `ai-chat-thread-item-menu-${surface}-${threadId}`;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
|
||||
|
||||
export const getDateGroupTitle = (key: DateGroupKey): string => {
|
||||
switch (key) {
|
||||
case 'today':
|
||||
return t`Today`;
|
||||
case 'yesterday':
|
||||
return t`Yesterday`;
|
||||
case 'older':
|
||||
return t`Older`;
|
||||
}
|
||||
};
|
||||
@@ -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()];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
type ThreadWithLastActivity = {
|
||||
lastMessageAt?: string | Date | null;
|
||||
updatedAt: string | Date;
|
||||
};
|
||||
|
||||
const getLastActivityMs = (thread: ThreadWithLastActivity): number =>
|
||||
new Date(thread.lastMessageAt ?? thread.updatedAt).getTime();
|
||||
|
||||
export const sortChatThreadsByLastActivityDesc = <
|
||||
T extends ThreadWithLastActivity,
|
||||
>(
|
||||
threads: T[],
|
||||
): T[] =>
|
||||
threads.toSorted((a, b) => getLastActivityMs(b) - getLastActivityMs(a));
|
||||
Reference in New Issue
Block a user