feat(topic): add completed status with dropdown action and filter (#14005)
Some checks are pending
E2E CI / Check Duplicate Run (push) Waiting to run
E2E CI / Test Web App (push) Blocked by required conditions
Release Desktop Canary / Calculate Canary Version (push) Waiting to run
Release Desktop Canary / Code quality check (push) Blocked by required conditions
Release Desktop Canary / Build Desktop App (push) Blocked by required conditions
Release Desktop Canary / Merge macOS Release Files (push) Blocked by required conditions
Release Desktop Canary / Publish Canary Release (push) Blocked by required conditions
Release Desktop Canary / Publish to S3 (push) Blocked by required conditions
Release Desktop Canary / Cleanup Old Canary Releases (push) Blocked by required conditions
Release ModelBank / Build ModelBank (push) Waiting to run
Release ModelBank / Publish ModelBank (push) Blocked by required conditions
Test CI / Check Duplicate Run (push) Waiting to run
Test CI / Test Packages (push) Blocked by required conditions
Test CI / Test App (shard 1/3) (push) Blocked by required conditions
Test CI / Test App (shard 2/3) (push) Blocked by required conditions
Test CI / Test App (shard 3/3) (push) Blocked by required conditions
Test CI / Merge and Upload App Coverage (push) Blocked by required conditions
Test CI / Test Desktop App (push) Blocked by required conditions
Test CI / Test Database (push) Blocked by required conditions

*  feat(topic): add completed status with dropdown action and filter

- Surface ChatTopicStatus (active/completed/archived) on topic list items and pass to dropdown menu
- Add markTopicCompleted / unmarkTopicCompleted store actions wired into the topic item dropdown
- Show CheckCircle2 icon on completed topics in the sidebar list
- Add topicIncludeCompleted user preference (default false) and an "Include Completed" toggle in the topic filter menu (agent + group routes)
- Wire excludeStatuses and triggers filters through TopicModel, TRPC router, service, and store SWR keys so completed topics are excluded by default

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🌐 i18n(topic): add zh-CN/en-US for completed status keys

Translate actions.markCompleted / actions.unmarkCompleted and filter.filter / filter.showCompleted for dev preview. CI's pnpm i18n will fill in remaining locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(topic): scope completed exclusion to routes with the toggle

Move the topicIncludeCompleted preference read out of the chat-store useFetchTopics action and into the (main) agent/group sidebars where the "Include Completed" filter actually lives. Popup and mobile topic views call useFetchTopics without excludeStatuses, so completed topics remain reachable on surfaces that don't expose the toggle (e.g. the popup window for a deep-linked completed topic, the mobile TopicModal).

Also switch ChatTopicStatus imports in the topic item / dropdown files to @lobechat/types to match the rest of the topic-feature imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(topic-model): cover excludeStatuses + triggers filters

Add cases to the TopicModel.query suite for the new params introduced alongside the topic.status column:
- triggers (positive trigger filter) on the container branch
- excludeStatuses on the container, agent, and groupId branches (verifies null status rows are still returned)
- status / completedAt are populated on returned items

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(topic): move "Mark Completed" to top of agent topic dropdown

Promote the completed-status toggle to the first menu item, with a divider before favorite, so the most-used status action sits at the top of the dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-21 17:37:09 +08:00 committed by GitHub
parent 61224fe76c
commit c0db58e622
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 452 additions and 41 deletions

View file

@ -15,20 +15,24 @@
"actions.export": "Export Topics", "actions.export": "Export Topics",
"actions.favorite": "Favorite", "actions.favorite": "Favorite",
"actions.import": "Import Conversation", "actions.import": "Import Conversation",
"actions.markCompleted": "Mark as Completed",
"actions.openInNewTab": "Open in New Tab", "actions.openInNewTab": "Open in New Tab",
"actions.openInNewWindow": "Open in a new window", "actions.openInNewWindow": "Open in a new window",
"actions.removeAll": "Delete All Topics", "actions.removeAll": "Delete All Topics",
"actions.removeUnstarred": "Delete Unstarred Topics", "actions.removeUnstarred": "Delete Unstarred Topics",
"actions.unfavorite": "Unfavorite", "actions.unfavorite": "Unfavorite",
"actions.unmarkCompleted": "Mark as Active",
"defaultTitle": "Default Topic", "defaultTitle": "Default Topic",
"displayItems": "Display Items", "displayItems": "Display Items",
"duplicateLoading": "Copying Topic...", "duplicateLoading": "Copying Topic...",
"duplicateSuccess": "Topic Copied Successfully", "duplicateSuccess": "Topic Copied Successfully",
"favorite": "Favorite", "favorite": "Favorite",
"filter.filter": "Filter",
"filter.groupMode.byProject": "By project", "filter.groupMode.byProject": "By project",
"filter.groupMode.byTime": "By time", "filter.groupMode.byTime": "By time",
"filter.groupMode.flat": "Flat", "filter.groupMode.flat": "Flat",
"filter.organize": "Organize", "filter.organize": "Organize",
"filter.showCompleted": "Include Completed",
"filter.sort": "Sort by", "filter.sort": "Sort by",
"filter.sortBy.createdAt": "Created time", "filter.sortBy.createdAt": "Created time",
"filter.sortBy.updatedAt": "Updated time", "filter.sortBy.updatedAt": "Updated time",

View file

@ -15,20 +15,24 @@
"actions.export": "导出话题", "actions.export": "导出话题",
"actions.favorite": "收藏", "actions.favorite": "收藏",
"actions.import": "导入对话", "actions.import": "导入对话",
"actions.markCompleted": "标为已完成",
"actions.openInNewTab": "在新标签页中打开", "actions.openInNewTab": "在新标签页中打开",
"actions.openInNewWindow": "打开独立窗口", "actions.openInNewWindow": "打开独立窗口",
"actions.removeAll": "删除全部话题", "actions.removeAll": "删除全部话题",
"actions.removeUnstarred": "删除未收藏话题", "actions.removeUnstarred": "删除未收藏话题",
"actions.unfavorite": "取消收藏", "actions.unfavorite": "取消收藏",
"actions.unmarkCompleted": "标为进行中",
"defaultTitle": "默认话题", "defaultTitle": "默认话题",
"displayItems": "显示条目", "displayItems": "显示条目",
"duplicateLoading": "话题复制中…", "duplicateLoading": "话题复制中…",
"duplicateSuccess": "话题复制成功", "duplicateSuccess": "话题复制成功",
"favorite": "收藏", "favorite": "收藏",
"filter.filter": "筛选",
"filter.groupMode.byProject": "按项目", "filter.groupMode.byProject": "按项目",
"filter.groupMode.byTime": "按时间阶段", "filter.groupMode.byTime": "按时间阶段",
"filter.groupMode.flat": "平铺", "filter.groupMode.flat": "平铺",
"filter.organize": "整理", "filter.organize": "整理",
"filter.showCompleted": "显示已完成",
"filter.sort": "排序", "filter.sort": "排序",
"filter.sortBy.createdAt": "按创建时间", "filter.sortBy.createdAt": "按创建时间",
"filter.sortBy.updatedAt": "按更新时间", "filter.sortBy.updatedAt": "按更新时间",

View file

@ -17,6 +17,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
enableInputMarkdown: true, enableInputMarkdown: true,
}, },
topicGroupMode: 'byTime', topicGroupMode: 'byTime',
topicIncludeCompleted: false,
topicSortBy: 'updatedAt', topicSortBy: 'updatedAt',
useCmdEnterToSend: false, useCmdEnterToSend: false,
}; };

View file

@ -207,6 +207,134 @@ describe('TopicModel - Query', () => {
expect(ids).toContain('null-trigger'); expect(ids).toContain('null-trigger');
expect(ids).not.toContain('cron-topic'); expect(ids).not.toContain('cron-topic');
}); });
it('should only return topics with matching triggers when triggers is set', async () => {
await serverDB.insert(topics).values([
{ id: 'normal-topic', sessionId, userId, title: 'Normal' },
{ id: 'cron-topic', sessionId, userId, title: 'Cron', trigger: 'cron' },
{ id: 'eval-topic', sessionId, userId, title: 'Eval', trigger: 'eval' },
]);
const result = await topicModel.query({
containerId: sessionId,
triggers: ['cron'],
});
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe('cron-topic');
});
it('should exclude topics with matching status via excludeStatuses, keeping null status', async () => {
const completedAt = new Date('2024-01-05');
await serverDB.insert(topics).values([
{ id: 'active-topic', sessionId, userId, title: 'Active', status: 'active' },
{
id: 'completed-topic',
sessionId,
userId,
title: 'Completed',
status: 'completed',
completedAt,
},
{ id: 'archived-topic', sessionId, userId, title: 'Archived', status: 'archived' },
{ id: 'null-status-topic', sessionId, userId, title: 'No status' },
]);
const result = await topicModel.query({
containerId: sessionId,
excludeStatuses: ['completed'],
});
const ids = result.items.map((t) => t.id);
expect(ids).toHaveLength(3);
expect(ids).toContain('active-topic');
expect(ids).toContain('archived-topic');
expect(ids).toContain('null-status-topic');
expect(ids).not.toContain('completed-topic');
});
it('should select status and completedAt on returned topics', async () => {
const completedAt = new Date('2024-02-01T10:00:00Z');
await serverDB.insert(topics).values([
{
id: 'with-status',
sessionId,
userId,
title: 'With Status',
status: 'completed',
completedAt,
},
]);
const result = await topicModel.query({ containerId: sessionId });
expect(result.items).toHaveLength(1);
expect(result.items[0].status).toBe('completed');
expect(result.items[0].completedAt?.toISOString()).toBe(completedAt.toISOString());
});
it('should apply excludeStatuses on the agent query branch', async () => {
await serverDB.transaction(async (trx) => {
await trx.insert(agents).values([{ id: 'status-agent', userId, title: 'Status Agent' }]);
await trx.insert(topics).values([
{
id: 'agent-active',
userId,
agentId: 'status-agent',
status: 'active',
updatedAt: new Date('2024-01-01'),
},
{
id: 'agent-completed',
userId,
agentId: 'status-agent',
status: 'completed',
updatedAt: new Date('2024-01-02'),
},
]);
});
const result = await topicModel.query({
agentId: 'status-agent',
excludeStatuses: ['completed'],
});
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe('agent-active');
expect(result.total).toBe(1);
});
it('should apply excludeStatuses on the groupId query branch', async () => {
await serverDB.transaction(async (trx) => {
await trx
.insert(chatGroups)
.values([{ id: 'status-group', title: 'Status Group', userId }]);
await trx.insert(topics).values([
{
id: 'group-active',
userId,
groupId: 'status-group',
status: 'active',
updatedAt: new Date('2024-01-01'),
},
{
id: 'group-completed',
userId,
groupId: 'status-group',
status: 'completed',
updatedAt: new Date('2024-01-02'),
},
]);
});
const result = await topicModel.query({
groupId: 'status-group',
excludeStatuses: ['completed'],
});
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe('group-active');
});
}); });
describe('query with agentId filter', () => { describe('query with agentId filter', () => {

View file

@ -28,6 +28,10 @@ interface QueryTopicParams {
*/ */
containerId?: string | null; containerId?: string | null;
current?: number; current?: number;
/**
* Exclude topics by status (e.g. ['completed'])
*/
excludeStatuses?: string[];
/** /**
* Exclude topics by trigger types (e.g. ['cron']) * Exclude topics by trigger types (e.g. ['cron'])
*/ */
@ -42,6 +46,10 @@ interface QueryTopicParams {
*/ */
isInbox?: boolean; isInbox?: boolean;
pageSize?: number; pageSize?: number;
/**
* Include only topics matching the given trigger types (positive filter)
*/
triggers?: string[];
} }
export interface ListTopicsForMemoryExtractorCursor { export interface ListTopicsForMemoryExtractorCursor {
@ -63,16 +71,27 @@ export class TopicModel {
agentId, agentId,
containerId, containerId,
current = 0, current = 0,
excludeStatuses,
excludeTriggers, excludeTriggers,
pageSize = 9999, pageSize = 9999,
groupId, groupId,
isInbox, isInbox,
triggers,
}: QueryTopicParams = {}) => { }: QueryTopicParams = {}) => {
const offset = current * pageSize; const offset = current * pageSize;
const excludeTriggerCondition = const excludeTriggerCondition =
excludeTriggers && excludeTriggers.length > 0 excludeTriggers && excludeTriggers.length > 0
? or(isNull(topics.trigger), not(inArray(topics.trigger, excludeTriggers))) ? or(isNull(topics.trigger), not(inArray(topics.trigger, excludeTriggers)))
: undefined; : undefined;
const triggerCondition =
triggers && triggers.length > 0 ? inArray(topics.trigger, triggers) : undefined;
const excludeStatusCondition =
excludeStatuses && excludeStatuses.length > 0
? or(
isNull(topics.status),
not(inArray(topics.status, excludeStatuses as ('active' | 'completed' | 'archived')[])),
)
: undefined;
// If groupId is provided, query topics by groupId directly // If groupId is provided, query topics by groupId directly
if (groupId) { if (groupId) {
@ -80,16 +99,20 @@ export class TopicModel {
eq(topics.userId, this.userId), eq(topics.userId, this.userId),
eq(topics.groupId, groupId), eq(topics.groupId, groupId),
excludeTriggerCondition, excludeTriggerCondition,
triggerCondition,
excludeStatusCondition,
); );
const [items, totalResult] = await Promise.all([ const [items, totalResult] = await Promise.all([
this.db this.db
.select({ .select({
completedAt: topics.completedAt,
createdAt: topics.createdAt, createdAt: topics.createdAt,
favorite: topics.favorite, favorite: topics.favorite,
historySummary: topics.historySummary, historySummary: topics.historySummary,
id: topics.id, id: topics.id,
metadata: topics.metadata, metadata: topics.metadata,
status: topics.status,
title: topics.title, title: topics.title,
updatedAt: topics.updatedAt, updatedAt: topics.updatedAt,
}) })
@ -145,26 +168,36 @@ export class TopicModel {
// Fetch items and total count in parallel // Fetch items and total count in parallel
// Include sessionId and agentId for migration detection // Include sessionId and agentId for migration detection
const agentWhere = and(
eq(topics.userId, this.userId),
agentCondition,
excludeTriggerCondition,
triggerCondition,
excludeStatusCondition,
);
const [items, totalResult] = await Promise.all([ const [items, totalResult] = await Promise.all([
this.db this.db
.select({ .select({
completedAt: topics.completedAt,
createdAt: topics.createdAt, createdAt: topics.createdAt,
favorite: topics.favorite, favorite: topics.favorite,
historySummary: topics.historySummary, historySummary: topics.historySummary,
id: topics.id, id: topics.id,
metadata: topics.metadata, metadata: topics.metadata,
status: topics.status,
title: topics.title, title: topics.title,
updatedAt: topics.updatedAt, updatedAt: topics.updatedAt,
}) })
.from(topics) .from(topics)
.where(and(eq(topics.userId, this.userId), agentCondition, excludeTriggerCondition)) .where(agentWhere)
.orderBy(desc(topics.favorite), desc(topics.updatedAt)) .orderBy(desc(topics.favorite), desc(topics.updatedAt))
.limit(pageSize) .limit(pageSize)
.offset(offset), .offset(offset),
this.db this.db
.select({ count: count(topics.id) }) .select({ count: count(topics.id) })
.from(topics) .from(topics)
.where(and(eq(topics.userId, this.userId), agentCondition, excludeTriggerCondition)), .where(agentWhere),
]); ]);
return { items, total: totalResult[0].count }; return { items, total: totalResult[0].count };
@ -175,18 +208,22 @@ export class TopicModel {
eq(topics.userId, this.userId), eq(topics.userId, this.userId),
this.matchContainer(containerId), this.matchContainer(containerId),
excludeTriggerCondition, excludeTriggerCondition,
triggerCondition,
excludeStatusCondition,
); );
const [items, totalResult] = await Promise.all([ const [items, totalResult] = await Promise.all([
this.db this.db
.select({ .select({
agentId: topics.agentId, agentId: topics.agentId,
completedAt: topics.completedAt,
createdAt: topics.createdAt, createdAt: topics.createdAt,
favorite: topics.favorite, favorite: topics.favorite,
historySummary: topics.historySummary, historySummary: topics.historySummary,
id: topics.id, id: topics.id,
metadata: topics.metadata, metadata: topics.metadata,
sessionId: topics.sessionId, sessionId: topics.sessionId,
status: topics.status,
title: topics.title, title: topics.title,
updatedAt: topics.updatedAt, updatedAt: topics.updatedAt,
}) })

View file

@ -104,11 +104,15 @@ export interface ChatTopicSummary {
provider: string; provider: string;
} }
export type ChatTopicStatus = 'active' | 'completed' | 'archived';
export interface ChatTopic extends Omit<BaseDataModel, 'meta'> { export interface ChatTopic extends Omit<BaseDataModel, 'meta'> {
completedAt?: Date | null;
favorite?: boolean; favorite?: boolean;
historySummary?: string; historySummary?: string;
metadata?: ChatTopicMetadata; metadata?: ChatTopicMetadata;
sessionId?: string; sessionId?: string;
status?: ChatTopicStatus | null;
title: string; title: string;
trigger?: string | null; trigger?: string | null;
} }
@ -160,6 +164,10 @@ export interface CreateTopicParams {
export interface QueryTopicParams { export interface QueryTopicParams {
agentId?: string | null; agentId?: string | null;
current?: number; current?: number;
/**
* Exclude topics by status (e.g. ['completed'])
*/
excludeStatuses?: string[];
/** /**
* Exclude topics by trigger types (e.g. ['cron']) * Exclude topics by trigger types (e.g. ['cron'])
*/ */
@ -174,6 +182,10 @@ export interface QueryTopicParams {
*/ */
isInbox?: boolean; isInbox?: boolean;
pageSize?: number; pageSize?: number;
/**
* Include only topics matching the given trigger types (positive filter)
*/
triggers?: string[];
} }
/** /**

View file

@ -75,6 +75,10 @@ export interface UserPreference {
*/ */
telemetry?: boolean | null; telemetry?: boolean | null;
topicGroupMode?: TopicGroupMode; topicGroupMode?: TopicGroupMode;
/**
* whether to include completed topics in the topic list
*/
topicIncludeCompleted?: boolean;
topicSortBy?: TopicSortBy; topicSortBy?: TopicSortBy;
/** /**
* whether to use cmd + enter to send message * whether to use cmd + enter to send message
@ -138,6 +142,7 @@ export const UserPreferenceSchema = z
lab: UserLabSchema.optional(), lab: UserLabSchema.optional(),
telemetry: z.boolean().nullable(), telemetry: z.boolean().nullable(),
topicGroupMode: z.enum(['byTime', 'byProject', 'flat']).optional(), topicGroupMode: z.enum(['byTime', 'byProject', 'flat']).optional(),
topicIncludeCompleted: z.boolean().optional(),
topicSortBy: z.enum(['createdAt', 'updatedAt']).optional(), topicSortBy: z.enum(['createdAt', 'updatedAt']).optional(),
useCmdEnterToSend: z.boolean().optional(), useCmdEnterToSend: z.boolean().optional(),
}) })

View file

@ -7,7 +7,10 @@ import { systemStatusSelectors } from '@/store/global/selectors';
/** /**
* Fetch topics for the current session (agent or group) * Fetch topics for the current session (agent or group)
*/ */
export const useFetchTopics = (options?: { excludeTriggers?: string[] }) => { export const useFetchTopics = (options?: {
excludeStatuses?: string[];
excludeTriggers?: string[];
}) => {
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent); const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
const [activeAgentId, activeGroupId, useFetchTopicsHook] = useChatStore((s) => [ const [activeAgentId, activeGroupId, useFetchTopicsHook] = useChatStore((s) => [
s.activeAgentId, s.activeAgentId,
@ -20,6 +23,9 @@ export const useFetchTopics = (options?: { excludeTriggers?: string[] }) => {
// If in group session, use groupId; otherwise use agentId // If in group session, use groupId; otherwise use agentId
const { isValidating, data } = useFetchTopicsHook(true, { const { isValidating, data } = useFetchTopicsHook(true, {
agentId: activeAgentId, agentId: activeAgentId,
...(options?.excludeStatuses && options.excludeStatuses.length > 0
? { excludeStatuses: options.excludeStatuses }
: {}),
...(options?.excludeTriggers && options.excludeTriggers.length > 0 ...(options?.excludeTriggers && options.excludeTriggers.length > 0
? { excludeTriggers: options.excludeTriggers } ? { excludeTriggers: options.excludeTriggers }
: {}), : {}),

View file

@ -15,6 +15,8 @@ export default {
'actions.duplicate': 'Duplicate', 'actions.duplicate': 'Duplicate',
'actions.favorite': 'Favorite', 'actions.favorite': 'Favorite',
'actions.unfavorite': 'Unfavorite', 'actions.unfavorite': 'Unfavorite',
'actions.markCompleted': 'Mark as Completed',
'actions.unmarkCompleted': 'Mark as Active',
'actions.export': 'Export Topics', 'actions.export': 'Export Topics',
'actions.import': 'Import Conversation', 'actions.import': 'Import Conversation',
'actions.openInNewTab': 'Open in New Tab', 'actions.openInNewTab': 'Open in New Tab',
@ -26,10 +28,12 @@ export default {
'duplicateLoading': 'Copying Topic...', 'duplicateLoading': 'Copying Topic...',
'duplicateSuccess': 'Topic Copied Successfully', 'duplicateSuccess': 'Topic Copied Successfully',
'favorite': 'Favorite', 'favorite': 'Favorite',
'filter.filter': 'Filter',
'filter.groupMode.byProject': 'By project', 'filter.groupMode.byProject': 'By project',
'filter.groupMode.byTime': 'By time', 'filter.groupMode.byTime': 'By time',
'filter.groupMode.flat': 'Flat', 'filter.groupMode.flat': 'Flat',
'filter.organize': 'Organize', 'filter.organize': 'Organize',
'filter.showCompleted': 'Include Completed',
'filter.sort': 'Sort by', 'filter.sort': 'Sort by',
'filter.sortBy.createdAt': 'Created time', 'filter.sortBy.createdAt': 'Created time',
'filter.sortBy.updatedAt': 'Updated time', 'filter.sortBy.updatedAt': 'Updated time',

View file

@ -165,6 +165,7 @@ const Content = memo<ContentProps>(({ open, searchKeyword }) => {
fav={topic.favorite} fav={topic.favorite}
id={topic.id} id={topic.id}
metadata={topic.metadata} metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId} threadId={activeThreadId}
title={topic.title} title={topic.title}
/> />

View file

@ -1,6 +1,7 @@
import type { ChatTopicMetadata, ChatTopicStatus } from '@lobechat/types';
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style'; import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style';
import { HashIcon, MessageSquareDashed } from 'lucide-react'; import { CheckCircle2, HashIcon, MessageSquareDashed } from 'lucide-react';
import { memo, Suspense, useCallback, useMemo, useRef } from 'react'; import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -14,7 +15,6 @@ import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat'; import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors'; import { operationSelectors } from '@/store/chat/selectors';
import { useElectronStore } from '@/store/electron'; import { useElectronStore } from '@/store/electron';
import type { ChatTopicMetadata } from '@/types/topic';
import { useTopicNavigation } from '../../hooks/useTopicNavigation'; import { useTopicNavigation } from '../../hooks/useTopicNavigation';
import ThreadList from '../../TopicListContent/ThreadList'; import ThreadList from '../../TopicListContent/ThreadList';
@ -75,11 +75,12 @@ interface TopicItemProps {
fav?: boolean; fav?: boolean;
id?: string; id?: string;
metadata?: ChatTopicMetadata; metadata?: ChatTopicMetadata;
status?: ChatTopicStatus | null;
threadId?: string; threadId?: string;
title: string; title: string;
} }
const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, metadata }) => { const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, metadata, status }) => {
const { t } = useTranslation('topic'); const { t } = useTranslation('topic');
const { isDarkMode } = useTheme(); const { isDarkMode } = useTheme();
const activeAgentId = useAgentStore((s) => s.activeAgentId); const activeAgentId = useAgentStore((s) => s.activeAgentId);
@ -147,9 +148,12 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
const { dropdownMenu } = useTopicItemDropdownMenu({ const { dropdownMenu } = useTopicItemDropdownMenu({
fav, fav,
id, id,
status,
title, title,
}); });
const isCompleted = status === 'completed';
const hasUnread = id && isUnreadCompleted; const hasUnread = id && isUnreadCompleted;
const unreadIcon = ( const unreadIcon = (
<span className={styles.unreadWrapper}> <span className={styles.unreadWrapper}>
@ -212,6 +216,15 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
/> />
); );
} }
if (isCompleted) {
return (
<Icon
icon={CheckCircle2}
size={'small'}
style={{ color: cssVar.colorTextDescription }}
/>
);
}
if (hasUnread) return unreadIcon; if (hasUnread) return unreadIcon;
if (metadata?.bot?.platform) { if (metadata?.bot?.platform) {
const ProviderIcon = getPlatformIcon(metadata.bot!.platform); const ProviderIcon = getPlatformIcon(metadata.bot!.platform);

View file

@ -1,7 +1,10 @@
import type { ChatTopicStatus } from '@lobechat/types';
import { type MenuProps } from '@lobehub/ui'; import { type MenuProps } from '@lobehub/ui';
import { Icon } from '@lobehub/ui'; import { Icon } from '@lobehub/ui';
import { App } from 'antd'; import { App } from 'antd';
import { import {
CheckCircle2,
Circle,
ExternalLink, ExternalLink,
Link2, Link2,
LucideCopy, LucideCopy,
@ -29,10 +32,16 @@ import { useGlobalStore } from '@/store/global';
interface TopicItemDropdownMenuProps { interface TopicItemDropdownMenuProps {
fav?: boolean; fav?: boolean;
id?: string; id?: string;
status?: ChatTopicStatus | null;
title: string; title: string;
} }
export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMenuProps) => { export const useTopicItemDropdownMenu = ({
fav,
id,
status,
title,
}: TopicItemDropdownMenuProps) => {
const { t } = useTranslation(['topic', 'common']); const { t } = useTranslation(['topic', 'common']);
const { modal, message } = App.useApp(); const { modal, message } = App.useApp();
const navigate = useNavigate(); const navigate = useNavigate();
@ -42,14 +51,25 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe
const addTab = useElectronStore((s) => s.addTab); const addTab = useElectronStore((s) => s.addTab);
const appOrigin = useAppOrigin(); const appOrigin = useAppOrigin();
const [autoRenameTopicTitle, duplicateTopic, removeTopic, favoriteTopic, updateTopicTitle] = const [
useChatStore((s) => [ autoRenameTopicTitle,
s.autoRenameTopicTitle, duplicateTopic,
s.duplicateTopic, removeTopic,
s.removeTopic, favoriteTopic,
s.favoriteTopic, markTopicCompleted,
s.updateTopicTitle, unmarkTopicCompleted,
]); updateTopicTitle,
] = useChatStore((s) => [
s.autoRenameTopicTitle,
s.duplicateTopic,
s.removeTopic,
s.favoriteTopic,
s.markTopicCompleted,
s.unmarkTopicCompleted,
s.updateTopicTitle,
]);
const isCompleted = status === 'completed';
const handleOpenShareModal = useCallback(() => { const handleOpenShareModal = useCallback(() => {
if (!id) return; if (!id) return;
@ -60,6 +80,21 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe
if (!id) return []; if (!id) return [];
return [ return [
{
icon: <Icon icon={isCompleted ? Circle : CheckCircle2} />,
key: 'markCompleted',
label: isCompleted ? t('actions.unmarkCompleted') : t('actions.markCompleted'),
onClick: () => {
if (isCompleted) {
unmarkTopicCompleted(id);
} else {
markTopicCompleted(id);
}
},
},
{
type: 'divider' as const,
},
{ {
icon: <Icon icon={Star} />, icon: <Icon icon={Star} />,
key: 'favorite', key: 'favorite',
@ -177,12 +212,15 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe
}, [ }, [
id, id,
fav, fav,
isCompleted,
title, title,
activeAgentId, activeAgentId,
appOrigin, appOrigin,
autoRenameTopicTitle, autoRenameTopicTitle,
duplicateTopic, duplicateTopic,
favoriteTopic, favoriteTopic,
markTopicCompleted,
unmarkTopicCompleted,
removeTopic, removeTopic,
updateTopicTitle, updateTopicTitle,
openTopicInNewWindow, openTopicInNewWindow,

View file

@ -75,6 +75,7 @@ const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeT
id={topic.id} id={topic.id}
key={topic.id} key={topic.id}
metadata={topic.metadata} metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId} threadId={activeThreadId}
title={topic.title} title={topic.title}
/> />

View file

@ -36,6 +36,7 @@ const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeT
id={topic.id} id={topic.id}
key={topic.id} key={topic.id}
metadata={topic.metadata} metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId} threadId={activeThreadId}
title={topic.title} title={topic.title}
/> />

View file

@ -45,6 +45,7 @@ const FlatMode = memo(() => {
id={topic.id} id={topic.id}
key={topic.id} key={topic.id}
metadata={topic.metadata} metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId} threadId={activeThreadId}
title={topic.title} title={topic.title}
/> />

View file

@ -37,6 +37,7 @@ const SearchResult = memo(() => {
id={topic.id} id={topic.id}
key={topic.id} key={topic.id}
metadata={topic.metadata} metadata={topic.metadata}
status={topic.status}
title={topic.title} title={topic.title}
/> />
))} ))}

View file

@ -9,6 +9,8 @@ import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import { useFetchTopics } from '@/hooks/useFetchTopics'; import { useFetchTopics } from '@/hooks/useFetchTopics';
import { useChatStore } from '@/store/chat'; import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors'; import { topicSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import Actions from './Actions'; import Actions from './Actions';
import Filter from './Filter'; import Filter from './Filter';
@ -22,8 +24,12 @@ interface TopicProps {
const Topic = memo<TopicProps>(({ itemKey }) => { const Topic = memo<TopicProps>(({ itemKey }) => {
const { t } = useTranslation(['topic', 'common']); const { t } = useTranslation(['topic', 'common']);
const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]); const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]);
const includeCompleted = useUserStore(preferenceSelectors.topicIncludeCompleted);
const dropdownMenu = useTopicActionsDropdownMenu(); const dropdownMenu = useTopicActionsDropdownMenu();
const { isRevalidating } = useFetchTopics({ excludeTriggers: ['cron', 'eval'] }); const { isRevalidating } = useFetchTopics({
excludeStatuses: includeCompleted ? undefined : ['completed'],
excludeTriggers: ['cron', 'eval'],
});
return ( return (
<AccordionItem <AccordionItem

View file

@ -11,11 +11,14 @@ import type { TopicGroupMode, TopicSortBy } from '@/types/topic';
export const useTopicFilterDropdownMenu = (): DropdownItem[] => { export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
const { t } = useTranslation('topic'); const { t } = useTranslation('topic');
const [topicGroupMode, topicSortBy, updatePreference] = useUserStore((s) => [ const [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference] = useUserStore(
preferenceSelectors.topicGroupMode(s), (s) => [
preferenceSelectors.topicSortBy(s), preferenceSelectors.topicGroupMode(s),
s.updatePreference, preferenceSelectors.topicSortBy(s),
]); preferenceSelectors.topicIncludeCompleted(s),
s.updatePreference,
],
);
return useMemo(() => { return useMemo(() => {
const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat']; const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat'];
@ -49,6 +52,22 @@ export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
label: t('filter.sort'), label: t('filter.sort'),
type: 'group' as const, type: 'group' as const,
}, },
{ type: 'divider' as const },
{
children: [
{
icon: topicIncludeCompleted ? <Icon icon={LucideCheck} /> : <div />,
key: 'showCompleted',
label: t('filter.showCompleted'),
onClick: () => {
updatePreference({ topicIncludeCompleted: !topicIncludeCompleted });
},
},
],
key: 'filter',
label: t('filter.filter'),
type: 'group' as const,
},
]; ];
}, [topicGroupMode, topicSortBy, updatePreference, t]); }, [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference, t]);
}; };

View file

@ -164,6 +164,7 @@ const Content = memo<ContentProps>(({ open, searchKeyword }) => {
active={activeTopicId === topic.id} active={activeTopicId === topic.id}
fav={topic.favorite} fav={topic.favorite}
id={topic.id} id={topic.id}
status={topic.status}
threadId={activeThreadId} threadId={activeThreadId}
title={topic.title} title={topic.title}
/> />

View file

@ -1,6 +1,7 @@
import type { ChatTopicStatus } from '@lobechat/types';
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style'; import { createStaticStyles, cssVar } from 'antd-style';
import { HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react'; import { CheckCircle2, HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react';
import { AnimatePresence, m } from 'motion/react'; import { AnimatePresence, m } from 'motion/react';
import { memo, Suspense, useCallback, useMemo, useRef } from 'react'; import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -61,11 +62,12 @@ interface TopicItemProps {
active?: boolean; active?: boolean;
fav?: boolean; fav?: boolean;
id?: string; id?: string;
status?: ChatTopicStatus | null;
threadId?: string; threadId?: string;
title: string; title: string;
} }
const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) => { const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, status }) => {
const { t } = useTranslation('topic'); const { t } = useTranslation('topic');
const toggleMobileTopic = useGlobalStore((s) => s.toggleMobileTopic); const toggleMobileTopic = useGlobalStore((s) => s.toggleMobileTopic);
const [activeGroupId, switchTopic] = useAgentGroupStore((s) => [s.activeGroupId, s.switchTopic]); const [activeGroupId, switchTopic] = useAgentGroupStore((s) => [s.activeGroupId, s.switchTopic]);
@ -137,9 +139,12 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
const dropdownMenu = useTopicItemDropdownMenu({ const dropdownMenu = useTopicItemDropdownMenu({
id, id,
status,
toggleEditing, toggleEditing,
}); });
const isCompleted = status === 'completed';
const hasUnread = id && isUnreadCompleted; const hasUnread = id && isUnreadCompleted;
const infoColor = cssVar.colorInfo; const infoColor = cssVar.colorInfo;
const unreadNode = ( const unreadNode = (
@ -222,13 +227,25 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
disabled={editing} disabled={editing}
href={!editing ? href : undefined} href={!editing ? href : undefined}
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title} title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
icon={ icon={(() => {
isLoading ? ( if (isLoading) {
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} /> return (
) : ( <Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
);
}
if (isCompleted) {
return (
<Icon
icon={CheckCircle2}
size={'small'}
style={{ color: cssVar.colorTextDescription }}
/>
);
}
return (
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} /> <Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
) );
} })()}
slots={{ slots={{
iconPostfix: unreadNode, iconPostfix: unreadNode,
}} }}

View file

@ -1,7 +1,18 @@
import type { ChatTopicStatus } from '@lobechat/types';
import { type MenuProps } from '@lobehub/ui'; import { type MenuProps } from '@lobehub/ui';
import { Icon } from '@lobehub/ui'; import { Icon } from '@lobehub/ui';
import { App } from 'antd'; import { App } from 'antd';
import { ExternalLink, Link2, LucideCopy, PanelTop, PencilLine, Trash, Wand2 } from 'lucide-react'; import {
CheckCircle2,
Circle,
ExternalLink,
Link2,
LucideCopy,
PanelTop,
PencilLine,
Trash,
Wand2,
} from 'lucide-react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -16,11 +27,13 @@ import { useGlobalStore } from '@/store/global';
interface TopicItemDropdownMenuProps { interface TopicItemDropdownMenuProps {
id?: string; id?: string;
status?: ChatTopicStatus | null;
toggleEditing: (visible?: boolean) => void; toggleEditing: (visible?: boolean) => void;
} }
export const useTopicItemDropdownMenu = ({ export const useTopicItemDropdownMenu = ({
id, id,
status,
toggleEditing, toggleEditing,
}: TopicItemDropdownMenuProps): (() => MenuProps['items']) => { }: TopicItemDropdownMenuProps): (() => MenuProps['items']) => {
const { t } = useTranslation(['topic', 'common']); const { t } = useTranslation(['topic', 'common']);
@ -32,16 +45,41 @@ export const useTopicItemDropdownMenu = ({
const addTab = useElectronStore((s) => s.addTab); const addTab = useElectronStore((s) => s.addTab);
const appOrigin = useAppOrigin(); const appOrigin = useAppOrigin();
const [autoRenameTopicTitle, duplicateTopic, removeTopic] = useChatStore((s) => [ const [
autoRenameTopicTitle,
duplicateTopic,
removeTopic,
markTopicCompleted,
unmarkTopicCompleted,
] = useChatStore((s) => [
s.autoRenameTopicTitle, s.autoRenameTopicTitle,
s.duplicateTopic, s.duplicateTopic,
s.removeTopic, s.removeTopic,
s.markTopicCompleted,
s.unmarkTopicCompleted,
]); ]);
const isCompleted = status === 'completed';
return useCallback(() => { return useCallback(() => {
if (!id) return []; if (!id) return [];
return [ return [
{
icon: <Icon icon={isCompleted ? Circle : CheckCircle2} />,
key: 'markCompleted',
label: isCompleted ? t('actions.unmarkCompleted') : t('actions.markCompleted'),
onClick: () => {
if (isCompleted) {
unmarkTopicCompleted(id);
} else {
markTopicCompleted(id);
}
},
},
{
type: 'divider' as const,
},
{ {
icon: <Icon icon={Wand2} />, icon: <Icon icon={Wand2} />,
key: 'autoRename', key: 'autoRename',
@ -131,10 +169,13 @@ export const useTopicItemDropdownMenu = ({
].filter(Boolean) as MenuProps['items']; ].filter(Boolean) as MenuProps['items'];
}, [ }, [
id, id,
isCompleted,
activeGroupId, activeGroupId,
appOrigin, appOrigin,
autoRenameTopicTitle, autoRenameTopicTitle,
duplicateTopic, duplicateTopic,
markTopicCompleted,
unmarkTopicCompleted,
removeTopic, removeTopic,
openGroupTopicInNewWindow, openGroupTopicInNewWindow,
addTab, addTab,

View file

@ -42,6 +42,7 @@ const GroupItem = memo<GroupItemProps>(({ group, activeTopicId, activeThreadId }
fav={topic.favorite} fav={topic.favorite}
id={topic.id} id={topic.id}
key={topic.id} key={topic.id}
status={topic.status}
threadId={activeThreadId} threadId={activeThreadId}
title={topic.title} title={topic.title}
/> />

View file

@ -44,6 +44,7 @@ const FlatMode = memo(() => {
fav={topic.favorite} fav={topic.favorite}
id={topic.id} id={topic.id}
key={topic.id} key={topic.id}
status={topic.status}
threadId={activeThreadId} threadId={activeThreadId}
title={topic.title} title={topic.title}
/> />

View file

@ -36,6 +36,7 @@ const SearchResult = memo(() => {
fav={topic.favorite} fav={topic.favorite}
id={topic.id} id={topic.id}
key={topic.id} key={topic.id}
status={topic.status}
title={topic.title} title={topic.title}
/> />
))} ))}

View file

@ -9,6 +9,8 @@ import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import { useFetchTopics } from '@/hooks/useFetchTopics'; import { useFetchTopics } from '@/hooks/useFetchTopics';
import { useChatStore } from '@/store/chat'; import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors'; import { topicSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import Actions from './Actions'; import Actions from './Actions';
import Filter from './Filter'; import Filter from './Filter';
@ -22,8 +24,11 @@ interface TopicProps {
const Topic = memo<TopicProps>(({ itemKey }) => { const Topic = memo<TopicProps>(({ itemKey }) => {
const { t } = useTranslation(['topic', 'common']); const { t } = useTranslation(['topic', 'common']);
const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]); const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]);
const includeCompleted = useUserStore(preferenceSelectors.topicIncludeCompleted);
const dropdownMenu = useTopicActionsDropdownMenu(); const dropdownMenu = useTopicActionsDropdownMenu();
const { isRevalidating } = useFetchTopics(); const { isRevalidating } = useFetchTopics({
excludeStatuses: includeCompleted ? undefined : ['completed'],
});
return ( return (
<AccordionItem <AccordionItem

View file

@ -11,11 +11,14 @@ import type { TopicGroupMode, TopicSortBy } from '@/types/topic';
export const useTopicFilterDropdownMenu = (): DropdownItem[] => { export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
const { t } = useTranslation('topic'); const { t } = useTranslation('topic');
const [topicGroupMode, topicSortBy, updatePreference] = useUserStore((s) => [ const [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference] = useUserStore(
preferenceSelectors.topicGroupMode(s), (s) => [
preferenceSelectors.topicSortBy(s), preferenceSelectors.topicGroupMode(s),
s.updatePreference, preferenceSelectors.topicSortBy(s),
]); preferenceSelectors.topicIncludeCompleted(s),
s.updatePreference,
],
);
return useMemo(() => { return useMemo(() => {
const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat']; const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat'];
@ -49,6 +52,22 @@ export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
label: t('filter.sort'), label: t('filter.sort'),
type: 'group' as const, type: 'group' as const,
}, },
{ type: 'divider' as const },
{
children: [
{
icon: topicIncludeCompleted ? <Icon icon={LucideCheck} /> : <div />,
key: 'showCompleted',
label: t('filter.showCompleted'),
onClick: () => {
updatePreference({ topicIncludeCompleted: !topicIncludeCompleted });
},
},
],
key: 'filter',
label: t('filter.filter'),
type: 'group' as const,
},
]; ];
}, [topicGroupMode, topicSortBy, updatePreference, t]); }, [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference, t]);
}; };

View file

@ -234,19 +234,28 @@ export const topicRouter = router({
z.object({ z.object({
agentId: z.string().nullable().optional(), agentId: z.string().nullable().optional(),
current: z.number().optional(), current: z.number().optional(),
excludeStatuses: z.array(z.string()).optional(),
excludeTriggers: z.array(z.string()).optional(), excludeTriggers: z.array(z.string()).optional(),
groupId: z.string().nullable().optional(), groupId: z.string().nullable().optional(),
isInbox: z.boolean().optional(), isInbox: z.boolean().optional(),
pageSize: z.number().optional(), pageSize: z.number().optional(),
sessionId: z.string().nullable().optional(), sessionId: z.string().nullable().optional(),
triggers: z.array(z.string()).optional(),
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { sessionId, isInbox, groupId, excludeTriggers, ...rest } = input; const { sessionId, isInbox, groupId, excludeStatuses, excludeTriggers, triggers, ...rest } =
input;
// If groupId is provided, query by groupId directly // If groupId is provided, query by groupId directly
if (groupId) { if (groupId) {
const result = await ctx.topicModel.query({ excludeTriggers, groupId, ...rest }); const result = await ctx.topicModel.query({
excludeStatuses,
excludeTriggers,
groupId,
triggers,
...rest,
});
return { items: result.items, total: result.total }; return { items: result.items, total: result.total };
} }
@ -259,8 +268,10 @@ export const topicRouter = router({
const result = await ctx.topicModel.query({ const result = await ctx.topicModel.query({
...rest, ...rest,
agentId: effectiveAgentId, agentId: effectiveAgentId,
excludeStatuses,
excludeTriggers, excludeTriggers,
isInbox, isInbox,
triggers,
}); });
// Runtime migration: backfill agentId for ALL legacy topics and messages under this agent // Runtime migration: backfill agentId for ALL legacy topics and messages under this agent
@ -524,6 +535,7 @@ export const topicRouter = router({
id: z.string(), id: z.string(),
value: z.object({ value: z.object({
agentId: z.string().optional(), agentId: z.string().optional(),
completedAt: z.date().nullable().optional(),
favorite: z.boolean().optional(), favorite: z.boolean().optional(),
historySummary: z.string().optional(), historySummary: z.string().optional(),
messages: z.array(z.string()).optional(), messages: z.array(z.string()).optional(),
@ -534,6 +546,7 @@ export const topicRouter = router({
}) })
.optional(), .optional(),
sessionId: z.string().optional(), sessionId: z.string().optional(),
status: z.enum(['active', 'completed', 'archived']).nullable().optional(),
title: z.string().optional(), title: z.string().optional(),
}), }),
}), }),

View file

@ -37,10 +37,12 @@ export class TopicService {
return lambdaClient.topic.getTopics.query({ return lambdaClient.topic.getTopics.query({
agentId: params.agentId, agentId: params.agentId,
current: params.current, current: params.current,
excludeStatuses: params.excludeStatuses,
excludeTriggers: params.excludeTriggers, excludeTriggers: params.excludeTriggers,
groupId: params.groupId, groupId: params.groupId,
isInbox: params.isInbox, isInbox: params.isInbox,
pageSize: params.pageSize, pageSize: params.pageSize,
triggers: params.triggers,
}) as any; }) as any;
}; };

View file

@ -234,6 +234,20 @@ export class ChatTopicActionImpl {
}); });
}; };
markTopicCompleted = async (id: string): Promise<void> => {
await this.#get().internal_updateTopic(id, {
completedAt: new Date(),
status: 'completed',
});
};
unmarkTopicCompleted = async (id: string): Promise<void> => {
await this.#get().internal_updateTopic(id, {
completedAt: null,
status: 'active',
});
};
favoriteTopic = async (id: string, favorite: boolean): Promise<void> => { favoriteTopic = async (id: string, favorite: boolean): Promise<void> => {
const { activeAgentId } = this.#get(); const { activeAgentId } = this.#get();
await this.#get().internal_updateTopic(id, { favorite }); await this.#get().internal_updateTopic(id, { favorite });
@ -303,12 +317,14 @@ export class ChatTopicActionImpl {
enable: boolean, enable: boolean,
{ {
agentId, agentId,
excludeStatuses,
excludeTriggers, excludeTriggers,
groupId, groupId,
pageSize: customPageSize, pageSize: customPageSize,
isInbox, isInbox,
}: { }: {
agentId?: string; agentId?: string;
excludeStatuses?: string[];
excludeTriggers?: string[]; excludeTriggers?: string[];
groupId?: string; groupId?: string;
isInbox?: boolean; isInbox?: boolean;
@ -318,6 +334,8 @@ export class ChatTopicActionImpl {
const pageSize = customPageSize || 20; const pageSize = customPageSize || 20;
const effectiveExcludeTriggers = const effectiveExcludeTriggers =
excludeTriggers && excludeTriggers.length > 0 ? excludeTriggers : undefined; excludeTriggers && excludeTriggers.length > 0 ? excludeTriggers : undefined;
const effectiveExcludeStatuses =
excludeStatuses && excludeStatuses.length > 0 ? excludeStatuses : undefined;
// Use topicMapKey to generate the container key for topic data map // Use topicMapKey to generate the container key for topic data map
const containerKey = topicMapKey({ agentId, groupId }); const containerKey = topicMapKey({ agentId, groupId });
const hasValidContainer = !!(groupId || agentId); const hasValidContainer = !!(groupId || agentId);
@ -331,6 +349,7 @@ export class ChatTopicActionImpl {
isInbox, isInbox,
pageSize, pageSize,
...(effectiveExcludeTriggers ? { excludeTriggers: effectiveExcludeTriggers } : {}), ...(effectiveExcludeTriggers ? { excludeTriggers: effectiveExcludeTriggers } : {}),
...(effectiveExcludeStatuses ? { excludeStatuses: effectiveExcludeStatuses } : {}),
}, },
] ]
: null, : null,
@ -353,6 +372,7 @@ export class ChatTopicActionImpl {
const result = await topicService.getTopics({ const result = await topicService.getTopics({
agentId, agentId,
current: 0, current: 0,
excludeStatuses: effectiveExcludeStatuses,
excludeTriggers: effectiveExcludeTriggers, excludeTriggers: effectiveExcludeTriggers,
groupId, groupId,
isInbox, isInbox,
@ -385,6 +405,7 @@ export class ChatTopicActionImpl {
...this.#get().topicDataMap, ...this.#get().topicDataMap,
[containerKey]: { [containerKey]: {
currentPage: 0, currentPage: 0,
excludeStatuses: effectiveExcludeStatuses,
excludeTriggers: effectiveExcludeTriggers, excludeTriggers: effectiveExcludeTriggers,
hasMore, hasMore,
isExpandingPageSize: false, isExpandingPageSize: false,
@ -426,9 +447,11 @@ export class ChatTopicActionImpl {
try { try {
const pageSize = useGlobalStore.getState().status.topicPageSize || 20; const pageSize = useGlobalStore.getState().status.topicPageSize || 20;
const excludeTriggers = currentData?.excludeTriggers; const excludeTriggers = currentData?.excludeTriggers;
const excludeStatuses = currentData?.excludeStatuses;
const result = await topicService.getTopics({ const result = await topicService.getTopics({
agentId: activeAgentId, agentId: activeAgentId,
current: nextPage, current: nextPage,
excludeStatuses,
excludeTriggers, excludeTriggers,
groupId: activeGroupId, groupId: activeGroupId,
pageSize, pageSize,
@ -443,6 +466,7 @@ export class ChatTopicActionImpl {
...this.#get().topicDataMap, ...this.#get().topicDataMap,
[key]: { [key]: {
currentPage: nextPage, currentPage: nextPage,
excludeStatuses,
excludeTriggers, excludeTriggers,
hasMore, hasMore,
isLoadingMore: false, isLoadingMore: false,

View file

@ -5,6 +5,7 @@ import { type ChatTopic } from '@/types/topic';
*/ */
export interface TopicData { export interface TopicData {
currentPage: number; currentPage: number;
excludeStatuses?: string[];
excludeTriggers?: string[]; excludeTriggers?: string[];
hasMore: boolean; hasMore: boolean;
isExpandingPageSize?: boolean; isExpandingPageSize?: boolean;

View file

@ -6,6 +6,8 @@ const useCmdEnterToSend = (s: UserStore): boolean => s.preference.useCmdEnterToS
const topicGroupMode = (s: UserStore) => const topicGroupMode = (s: UserStore) =>
s.preference.topicGroupMode || DEFAULT_PREFERENCE.topicGroupMode!; s.preference.topicGroupMode || DEFAULT_PREFERENCE.topicGroupMode!;
const topicSortBy = (s: UserStore) => s.preference.topicSortBy || DEFAULT_PREFERENCE.topicSortBy!; const topicSortBy = (s: UserStore) => s.preference.topicSortBy || DEFAULT_PREFERENCE.topicSortBy!;
const topicIncludeCompleted = (s: UserStore): boolean =>
s.preference.topicIncludeCompleted ?? false;
const hideSyncAlert = (s: UserStore) => s.preference.hideSyncAlert; const hideSyncAlert = (s: UserStore) => s.preference.hideSyncAlert;
@ -26,6 +28,7 @@ export const preferenceSelectors = {
shouldTriggerFileInKnowledgeBaseTip, shouldTriggerFileInKnowledgeBaseTip,
showUploadFileInKnowledgeBaseTip, showUploadFileInKnowledgeBaseTip,
topicGroupMode, topicGroupMode,
topicIncludeCompleted,
topicSortBy, topicSortBy,
useCmdEnterToSend, useCmdEnterToSend,
}; };