[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:
nitin
2026-04-30 21:12:10 +05:30
committed by GitHub
parent 4b76457217
commit e1828b6f41
111 changed files with 2915 additions and 1139 deletions
@@ -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));