Compare commits

...

5 Commits

Author SHA1 Message Date
YuTengjing 4fde91b9e5 💄 style: compact task template recommendations 2026-05-09 20:53:06 +08:00
YuTengjing bf13adebf4 💄 style: simplify empty task recommendation header 2026-05-09 20:53:06 +08:00
YuTengjing fcbad78b1d 💄 style: refine task template empty recommendations 2026-05-09 20:53:06 +08:00
YuTengjing 68b8d17781 💄 style: tighten task recommendation empty state 2026-05-09 20:53:06 +08:00
YuTengjing 0e4a937a70 feat: show task templates on empty tasks page 2026-05-09 20:53:06 +08:00
17 changed files with 755 additions and 215 deletions
+1 -1
View File
@@ -214,7 +214,7 @@
"schedule.daily": "Every day at {{time}}",
"schedule.editableAfterCreateTooltip": "You can adjust the schedule after creating the task.",
"schedule.weekly": "Every {{weekday}} at {{time}}",
"section.title": "Try these scheduled tasks",
"section.title": "Try these tasks",
"seo-weekly-report.description": "Every Monday, ranking movement, emerging keywords, and pages worth refreshing.",
"seo-weekly-report.prompt": "Every Monday at 09:00, give me a lightweight SEO weekly: top ranking movers (up/down), 5 emerging keywords worth targeting, and 3 existing pages ripe for a content refresh.",
"seo-weekly-report.title": "SEO weekly report",
+1 -1
View File
@@ -214,7 +214,7 @@
"schedule.daily": "每天 {{time}}",
"schedule.editableAfterCreateTooltip": "创建后可调整",
"schedule.weekly": "每{{weekday}} {{time}}",
"section.title": "试试这些定时任务",
"section.title": "试试这些任务",
"seo-weekly-report.description": "每周一一份轻量 SEO 报告:排名变化、新关键词机会、值得翻新的页面",
"seo-weekly-report.prompt": "每周一早上 9:00 给我一份轻量 SEO 周报:排名升 / 降 TOP 变动、5 个值得关注的新关键词、3 个适合内容翻新的老页面。",
"seo-weekly-report.title": "SEO 排名周报",
@@ -0,0 +1,199 @@
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TaskTemplateRecommendationsUIState } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
import KanbanBoard from './KanbanBoard';
const mocks = vi.hoisted(() => ({
recommendationsState: { mode: 'hidden' } as TaskTemplateRecommendationsUIState,
taskState: {
isTaskGroupListInit: true,
taskGroups: [] as Array<{ key: string; tasks: unknown[]; total: number }>,
updateTaskStatus: vi.fn(),
useFetchTaskGroupList: vi.fn(),
},
useTaskTemplateRecommendationsUI: vi.fn(),
}));
vi.mock('@dnd-kit/core', () => {
const Wrapper = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
return {
DndContext: Wrapper,
DragOverlay: Wrapper,
KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(),
pointerWithin: vi.fn(),
useSensor: vi.fn((sensor, options) => ({ options, sensor })),
useSensors: vi.fn((...sensors) => sensors),
};
});
vi.mock('@lobehub/ui', () => {
const Div = ({ children, ...props }: any) => <div {...props}>{children}</div>;
return {
Block: Div,
Center: Div,
Empty: ({ description }: { description: string }) => <div>{description}</div>,
Flexbox: Div,
Icon: () => <span data-testid="icon" />,
Text: Div,
};
});
vi.mock('antd-style', () => ({
cssVar: {
colorFillSecondary: '#222',
colorTextSecondary: '#999',
},
createStaticStyles: () => ({
board: 'board',
}),
}));
vi.mock('react-i18next', () => ({
useTranslation: (namespace: string) => ({
t: (key: string) => {
const translations: Record<string, Record<string, string>> = {
chat: {
'taskList.empty': 'No tasks yet',
},
taskTemplate: {
'section.title': 'Try these tasks',
},
};
return translations[namespace]?.[key] ?? key;
},
}),
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@/features/RecommendTaskTemplates/TaskTemplateRecommendationsView', () => ({
TaskTemplateRecommendationsView: ({
state,
variant,
}: {
state: TaskTemplateRecommendationsUIState;
variant?: string;
}) => (
<div data-testid="task-template-recommendations">
{state.mode}:{variant}
</div>
),
}));
vi.mock('@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI', () => ({
useTaskTemplateRecommendationsUI: mocks.useTaskTemplateRecommendationsUI,
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (
selector: (state: {
hiddenColumns: string[];
hiddenPanelCollapsed: boolean;
updateSystemStatus: () => void;
}) => unknown,
) =>
selector({
hiddenColumns: [],
hiddenPanelCollapsed: false,
updateSystemStatus: vi.fn(),
}),
}));
vi.mock('@/store/global/selectors', () => ({
systemStatusSelectors: {
taskKanbanHiddenColumns: (state: { hiddenColumns: string[] }) => state.hiddenColumns,
taskKanbanHiddenPanelCollapsed: (state: { hiddenPanelCollapsed: boolean }) =>
state.hiddenPanelCollapsed,
},
}));
vi.mock('@/store/task', () => ({
useTaskStore: (selector: (state: typeof mocks.taskState) => unknown) => selector(mocks.taskState),
}));
vi.mock('../CreateTaskModal', () => ({
createTaskModal: vi.fn(),
}));
vi.mock('../features/AgentTaskItem', () => ({
default: ({ task }: { task: { identifier: string } }) => <div>{task.identifier}</div>,
}));
vi.mock('./HiddenColumnsPanel', () => ({
default: () => <div data-testid="hidden-columns-panel" />,
}));
vi.mock('./KanbanColumn', () => ({
COLUMN_I18N_KEYS: {
backlog: 'taskList.status.backlog',
canceled: 'taskList.status.canceled',
done: 'taskList.status.done',
needsInput: 'taskList.status.needsInput',
running: 'taskList.status.running',
},
COLUMN_STATUS_ICON: {
backlog: null,
canceled: null,
done: null,
needsInput: null,
running: null,
},
COLUMN_WIDTH: 280,
default: ({ columnKey, loading }: { columnKey: string; loading?: boolean }) => (
<div data-testid={`kanban-column-${columnKey}`}>{loading ? 'loading' : columnKey}</div>
),
}));
describe('KanbanBoard recommendations empty state', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.recommendationsState = { mode: 'hidden' };
mocks.taskState = {
isTaskGroupListInit: true,
taskGroups: [],
updateTaskStatus: vi.fn(),
useFetchTaskGroupList: vi.fn(),
};
mocks.useTaskTemplateRecommendationsUI.mockImplementation(() => mocks.recommendationsState);
});
it('renders recommended task templates when the kanban task list is empty', () => {
mocks.recommendationsState = {
mode: 'cards',
onCreated: vi.fn(),
onDismiss: vi.fn(),
recommendationBatchId: 'batch-1',
templates: [],
userInterestCount: 0,
};
render(<KanbanBoard />);
expect(screen.getByText('Try these tasks')).toBeInTheDocument();
expect(screen.getByTestId('task-template-recommendations')).toHaveTextContent('cards:compact');
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: true });
});
it('keeps the recommendation request disabled while kanban columns are loading', () => {
mocks.taskState = {
isTaskGroupListInit: false,
taskGroups: [],
updateTaskStatus: vi.fn(),
useFetchTaskGroupList: vi.fn(),
};
render(<KanbanBoard />);
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: false });
expect(screen.queryByTestId('task-template-recommendations')).not.toBeInTheDocument();
});
});
@@ -9,13 +9,13 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { Center, Empty, Flexbox } from '@lobehub/ui';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ClipboardCheckIcon } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useTaskTemplateRecommendationsUI } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useTaskStore } from '@/store/task';
@@ -26,6 +26,7 @@ import { createTaskModal } from '../CreateTaskModal';
import AgentTaskItem from '../features/AgentTaskItem';
import HiddenColumnsPanel from './HiddenColumnsPanel';
import KanbanColumn, { COLUMN_I18N_KEYS, COLUMN_STATUS_ICON, COLUMN_WIDTH } from './KanbanColumn';
import { RecommendedTaskTemplatesEmptyState } from './RecommendedTaskTemplatesEmptyState';
const styles = createStaticStyles(({ css }) => ({
board: css`
@@ -182,6 +183,11 @@ const KanbanBoard = memo(() => {
[hiddenColumnSet, t, taskGroups],
);
const totalTasks = useMemo(() => taskGroups.reduce((sum, g) => sum + g.total, 0), [taskGroups]);
const recommendationState = useTaskTemplateRecommendationsUI({
enabled: isInit && totalTasks === 0,
});
if (!isInit) {
return (
<Flexbox horizontal className={styles.board}>
@@ -199,14 +205,8 @@ const KanbanBoard = memo(() => {
);
}
const totalTasks = taskGroups.reduce((sum, g) => sum + g.total, 0);
if (totalTasks === 0) {
return (
<Center height={'80vh'} width={'100%'}>
<Empty description={t('taskList.empty')} icon={ClipboardCheckIcon} />
</Center>
);
return <RecommendedTaskTemplatesEmptyState recommendationState={recommendationState} />;
}
return (
@@ -0,0 +1,41 @@
import { Empty, Flexbox, Text } from '@lobehub/ui';
import { ClipboardCheckIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { TaskTemplateRecommendationsView } from '@/features/RecommendTaskTemplates/TaskTemplateRecommendationsView';
import type { TaskTemplateRecommendationsUIState } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
interface RecommendedTaskTemplatesEmptyStateProps {
recommendationState: TaskTemplateRecommendationsUIState;
}
export const RecommendedTaskTemplatesEmptyState = memo<RecommendedTaskTemplatesEmptyStateProps>(
({ recommendationState }) => {
const { t } = useTranslation('chat');
const { t: tTaskTemplate } = useTranslation('taskTemplate');
if (recommendationState.mode === 'hidden') {
return (
<Flexbox align={'center'} paddingBlock={32} style={{ width: '100%' }}>
<Empty description={t('taskList.empty')} icon={ClipboardCheckIcon} />
</Flexbox>
);
}
return (
<Flexbox
gap={12}
paddingBlock={'16px 24px'}
style={{ marginInline: 'auto', maxWidth: 720, width: '100%' }}
>
<Text fontSize={15} weight={600}>
{tTaskTemplate('section.title')}
</Text>
<TaskTemplateRecommendationsView state={recommendationState} variant={'compact'} />
</Flexbox>
);
},
);
RecommendedTaskTemplatesEmptyState.displayName = 'RecommendedTaskTemplatesEmptyState';
@@ -0,0 +1,169 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TaskTemplateRecommendationsUIState } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
import { DEFAULT_TASK_LIST_VIEW_OPTIONS } from './listViewOptions';
import TaskList from './TaskList';
const mocks = vi.hoisted(() => ({
recommendationsState: { mode: 'hidden' } as TaskTemplateRecommendationsUIState,
taskState: {
isTaskListInit: true,
tasks: [] as Array<{ identifier: string; status: string }>,
},
useTaskTemplateRecommendationsUI: vi.fn(),
}));
vi.mock('@lobehub/ui', () => {
const Div = ({ children, ...props }: any) => <div {...props}>{children}</div>;
return {
Accordion: Div,
AccordionItem: ({ children, title }: any) => (
<section>
<header>{title}</header>
{children}
</section>
),
Block: Div,
Center: Div,
Empty: ({ description }: { description: string }) => <div>{description}</div>,
Flexbox: Div,
Icon: () => <span data-testid="icon" />,
Skeleton: {
Avatar: () => <div data-testid="skeleton-avatar" />,
Button: () => <div data-testid="skeleton-button" />,
Input: () => <div data-testid="skeleton-input" />,
},
Text: Div,
};
});
vi.mock('antd', () => ({
Divider: () => <hr />,
}));
vi.mock('antd-style', () => ({
cssVar: {
colorBorder: '#ddd',
colorTextDescription: '#999',
orange: '#f60',
},
}));
vi.mock('react-i18next', () => ({
useTranslation: (namespace: string) => ({
t: (key: string) => {
const translations: Record<string, Record<string, string>> = {
chat: {
'taskList.empty': 'No tasks yet',
},
taskTemplate: {
'section.title': 'Try these tasks',
},
};
return translations[namespace]?.[key] ?? key;
},
}),
}));
vi.mock('@/features/RecommendTaskTemplates/TaskTemplateRecommendationsView', () => ({
TaskTemplateRecommendationsView: ({
state,
variant,
}: {
state: TaskTemplateRecommendationsUIState;
variant?: string;
}) => (
<div data-testid="task-template-recommendations">
{state.mode}:{variant}
</div>
),
}));
vi.mock('@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI', () => ({
useTaskTemplateRecommendationsUI: mocks.useTaskTemplateRecommendationsUI,
}));
vi.mock('@/store/task', () => ({
useTaskStore: (selector: (state: typeof mocks.taskState) => unknown) => selector(mocks.taskState),
}));
vi.mock('../features/AgentTaskItem', () => ({
default: ({ task }: { task: { identifier: string } }) => <div>{task.identifier}</div>,
}));
vi.mock('../features/AssigneeAvatar', () => ({
default: () => <span data-testid="assignee-avatar" />,
}));
vi.mock('../features/icons/PriorityHighIcon', () => ({
default: () => <span data-testid="priority-high-icon" />,
}));
vi.mock('../features/icons/PriorityLowIcon', () => ({
default: () => <span data-testid="priority-low-icon" />,
}));
vi.mock('../features/icons/PriorityMediumIcon', () => ({
default: () => <span data-testid="priority-medium-icon" />,
}));
vi.mock('../features/icons/PriorityNoneIcon', () => ({
default: () => <span data-testid="priority-none-icon" />,
}));
vi.mock('../features/icons/PriorityUrgentIcon', () => ({
default: () => <span data-testid="priority-urgent-icon" />,
}));
vi.mock('../features/TaskStatusIcon', () => ({
default: () => <span data-testid="task-status-icon" />,
}));
vi.mock('../shared/useAgentDisplayMeta', () => ({
useAgentDisplayMeta: () => ({ title: 'Agent' }),
}));
describe('TaskList recommendations empty state', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.recommendationsState = { mode: 'hidden' };
mocks.taskState = {
isTaskListInit: true,
tasks: [],
};
mocks.useTaskTemplateRecommendationsUI.mockImplementation(() => mocks.recommendationsState);
});
it('renders recommended task templates on the empty task list', () => {
mocks.recommendationsState = {
mode: 'cards',
onCreated: vi.fn(),
onDismiss: vi.fn(),
recommendationBatchId: 'batch-1',
templates: [],
userInterestCount: 0,
};
render(<TaskList options={DEFAULT_TASK_LIST_VIEW_OPTIONS} />);
expect(screen.getByText('Try these tasks')).toBeInTheDocument();
expect(screen.getByTestId('task-template-recommendations')).toHaveTextContent('cards:compact');
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: true });
});
it('keeps the recommendation request disabled while the task list is still loading', () => {
mocks.taskState = {
isTaskListInit: false,
tasks: [],
};
render(<TaskList options={DEFAULT_TASK_LIST_VIEW_OPTIONS} />);
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: false });
expect(screen.queryByTestId('task-template-recommendations')).not.toBeInTheDocument();
});
});
@@ -1,10 +1,11 @@
import { Accordion, AccordionItem, Block, Center, Empty, Flexbox, Icon, Text } from '@lobehub/ui';
import { Accordion, AccordionItem, Block, Flexbox, Icon, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cssVar } from 'antd-style';
import { ClipboardCheckIcon, UserRound } from 'lucide-react';
import { UserRound } from 'lucide-react';
import { Fragment, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useTaskTemplateRecommendationsUI } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
import { useTaskStore } from '@/store/task';
import { taskListSelectors } from '@/store/task/selectors';
@@ -24,6 +25,7 @@ import {
HIDDEN_WHEN_COMPLETED_STATUSES,
sortGroupEntries,
} from './listViewOptions';
import { RecommendedTaskTemplatesEmptyState } from './RecommendedTaskTemplatesEmptyState';
import TaskItemSkeleton from './TaskItemSkeleton';
interface TaskListProps {
@@ -122,6 +124,9 @@ const TaskList = memo<TaskListProps>(({ onShowHiddenCompleted, options }) => {
const { t } = useTranslation('chat');
const tasks = useTaskStore(taskListSelectors.taskList);
const isInit = useTaskStore(taskListSelectors.isTaskListInit);
const recommendationState = useTaskTemplateRecommendationsUI({
enabled: isInit && tasks.length === 0,
});
const groupBy = normalizeGroupBy(options.groupBy, 'status');
const subGroupBy = normalizeGroupBy(options.subGroupBy, 'none');
const effectiveSubGroupBy = groupBy === 'none' ? 'none' : subGroupBy;
@@ -207,11 +212,7 @@ const TaskList = memo<TaskListProps>(({ onShowHiddenCompleted, options }) => {
}
if (tasks.length === 0) {
return (
<Center height={'80vh'} width={'100%'}>
<Empty description={t('taskList.empty')} icon={ClipboardCheckIcon} />
</Center>
);
return <RecommendedTaskTemplatesEmptyState recommendationState={recommendationState} />;
}
const hiddenFooter = hiddenCount > 0 && (
@@ -1,43 +1,2 @@
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { BriefCardSkeleton } from '@/features/DailyBrief/BriefCardSkeleton';
import { TaskTemplateCard } from './TaskTemplateCard';
import type { DailyBriefRecommendationsUIState } from './useDailyBriefRecommendationsUI';
interface DailyBriefRecommendationsViewProps {
state: DailyBriefRecommendationsUIState;
}
export const DailyBriefRecommendationsView = memo<DailyBriefRecommendationsViewProps>(
({ state }) => {
if (state.mode === 'hidden') return null;
if (state.mode === 'skeleton') {
return (
<Flexbox gap={8}>
<BriefCardSkeleton />
<BriefCardSkeleton />
</Flexbox>
);
}
return (
<Flexbox gap={8}>
{state.templates.map((tmpl, index) => (
<TaskTemplateCard
key={tmpl.id}
position={index}
recommendationBatchId={state.recommendationBatchId}
template={tmpl}
userInterestCount={state.userInterestCount}
onCreated={state.onCreated}
onDismiss={state.onDismiss}
/>
))}
</Flexbox>
);
},
);
DailyBriefRecommendationsView.displayName = 'DailyBriefRecommendationsView';
export type { TaskTemplateRecommendationsViewProps as DailyBriefRecommendationsViewProps } from './TaskTemplateRecommendationsView';
export { TaskTemplateRecommendationsView as DailyBriefRecommendationsView } from './TaskTemplateRecommendationsView';
@@ -268,6 +268,25 @@ afterEach(() => {
});
describe('TaskTemplateCard analytics', () => {
it('renders the compact variant for dense recommendation surfaces', () => {
render(
<TaskTemplateCard
position={0}
recommendationBatchId="batch-1"
template={makeTemplate()}
userInterestCount={1}
variant="compact"
onCreated={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(screen.getByText('Template A')).toBeInTheDocument();
expect(screen.getByText('Daily')).toBeInTheDocument();
expect(screen.getByText('Template description')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument();
});
it('does not mark an impression as tracked before analytics is ready', () => {
mocks.analyticsEnabled = false;
const { rerender } = render(
@@ -59,10 +59,19 @@ interface TaskTemplateCardProps {
recommendationBatchId: string;
template: RecommendedTaskTemplate;
userInterestCount: number;
variant?: 'compact' | 'default';
}
export const TaskTemplateCard = memo<TaskTemplateCardProps>(
({ onCreated, onDismiss, position, recommendationBatchId, template, userInterestCount }) => {
({
onCreated,
onDismiss,
position,
recommendationBatchId,
template,
userInterestCount,
variant = 'default',
}) => {
const { t } = useTranslation('taskTemplate');
const { t: tSetting } = useTranslation('setting');
const { analytics } = useAnalytics();
@@ -356,6 +365,58 @@ export const TaskTemplateCard = memo<TaskTemplateCardProps>(
</button>
);
if (variant === 'compact') {
return (
<Block
className={cx(styles.card, styles.compactCard)}
gap={10}
padding={12}
ref={cardRef}
style={{ borderRadius: cssVar.borderRadiusLG }}
variant={'outlined'}
>
<Flexbox horizontal align={'flex-start'} gap={10} justify={'space-between'}>
<Flexbox gap={4} style={{ flex: 1, minWidth: 0 }}>
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
<TemplateBriefIcon icon={IconComp} />
<Text ellipsis fontSize={15} weight={500}>
{title}
</Text>
</Flexbox>
<Flexbox horizontal align={'center'} className={styles.meta} gap={4}>
<Icon icon={Clock} size={12} />
<Text fontSize={12} style={{ color: 'inherit' }}>
{scheduleText}
</Text>
</Flexbox>
</Flexbox>
<ActionIcon
className={`${styles.dismissBtn} task-template-dismiss`}
icon={X}
size={'small'}
title={t('action.dismiss.tooltip')}
onClick={handleDismiss}
/>
</Flexbox>
{description.trim().length > 0 ? (
<Text className={styles.compactDescription} fontSize={13} type={'secondary'}>
{description}
</Text>
) : null}
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'} wrap={'wrap'}>
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
{hintNode}
</Flexbox>
<Flexbox horizontal align={'center'} gap={8}>
{primaryButton}
</Flexbox>
</Flexbox>
</Block>
);
}
return (
<Block
className={cx(briefStyles.card, styles.card)}
@@ -0,0 +1,63 @@
import { Flexbox, Grid } from '@lobehub/ui';
import { memo } from 'react';
import { BriefCardSkeleton } from '@/features/DailyBrief/BriefCardSkeleton';
import { TaskTemplateCard } from './TaskTemplateCard';
import type { TaskTemplateRecommendationsUIState } from './useTaskTemplateRecommendationsUI';
export interface TaskTemplateRecommendationsViewProps {
state: TaskTemplateRecommendationsUIState;
variant?: 'compact' | 'default';
}
export const TaskTemplateRecommendationsView = memo<TaskTemplateRecommendationsViewProps>(
({ state, variant = 'default' }) => {
if (state.mode === 'hidden') return null;
if (state.mode === 'skeleton') {
return (
<Flexbox gap={8}>
<BriefCardSkeleton />
<BriefCardSkeleton />
</Flexbox>
);
}
if (variant === 'compact') {
return (
<Grid gap={8} maxItemWidth={340} style={{ width: '100%' }}>
{state.templates.map((tmpl, index) => (
<TaskTemplateCard
key={tmpl.id}
position={index}
recommendationBatchId={state.recommendationBatchId}
template={tmpl}
userInterestCount={state.userInterestCount}
variant={'compact'}
onCreated={state.onCreated}
onDismiss={state.onDismiss}
/>
))}
</Grid>
);
}
return (
<Flexbox gap={8}>
{state.templates.map((tmpl, index) => (
<TaskTemplateCard
key={tmpl.id}
position={index}
recommendationBatchId={state.recommendationBatchId}
template={tmpl}
userInterestCount={state.userInterestCount}
onCreated={state.onCreated}
onDismiss={state.onDismiss}
/>
))}
</Flexbox>
);
},
);
TaskTemplateRecommendationsView.displayName = 'TaskTemplateRecommendationsView';
@@ -11,6 +11,22 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
opacity: 1;
}
`,
compactCard: css`
min-height: 168px;
.ant-btn {
height: 32px;
padding-inline: 12px !important;
}
`,
compactDescription: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.5;
`,
dismissBtn: css`
pointer-events: none;
flex-shrink: 0;
@@ -1,145 +1,2 @@
import type { RecommendedTaskTemplate, TaskTemplateSkillSource } from '@lobechat/const';
import { useAnalytics } from '@lobehub/analytics/react';
import { App } from 'antd';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import { taskTemplateService } from '@/services/taskTemplate';
import { useBriefStore } from '@/store/brief';
import { briefListSelectors } from '@/store/brief/selectors';
import { useToolStore } from '@/store/tool';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
import { createRecommendationBatchId, getTaskTemplateListServedProperties } from './analytics';
import { useResolvedInterestKeys } from './useResolvedInterestKeys';
export type DailyBriefRecommendationsUIState =
| { mode: 'hidden' }
| { mode: 'skeleton' }
| {
mode: 'cards';
onCreated: (templateId: string) => void;
onDismiss: (templateId: string) => void;
recommendationBatchId: string;
templates: RecommendedTaskTemplate[];
userInterestCount: number;
};
export function useDailyBriefRecommendationsUI(): DailyBriefRecommendationsUIState {
const { t } = useTranslation('taskTemplate');
const { analytics } = useAnalytics();
const { message } = App.useApp();
const isLogin = useUserStore(authSelectors.isLogin);
const useFetchBriefs = useBriefStore((s) => s.useFetchBriefs);
useFetchBriefs(isLogin);
const isInit = useBriefStore(briefListSelectors.isBriefsInit);
const interestKeys = useResolvedInterestKeys();
const swrKey = interestKeys ? [...interestKeys].sort().join(',') : '';
const swrEnabled = isLogin && interestKeys !== null;
const batchRef = useRef<
| {
id: string;
served: boolean;
swrKey: string;
}
| undefined
>(undefined);
const { data, isLoading, mutate } = useSWR(
swrEnabled ? ['taskTemplate.listDailyRecommend', swrKey] : null,
async () => taskTemplateService.listDailyRecommend(interestKeys ?? []),
{ revalidateOnFocus: false, revalidateOnReconnect: false },
);
const templates = useMemo(() => data?.data ?? [], [data]);
if (templates.length > 0 && batchRef.current?.swrKey !== swrKey) {
batchRef.current = {
id: createRecommendationBatchId(),
served: false,
swrKey,
};
}
const recommendationBatchId = batchRef.current?.id;
const userInterestCount = interestKeys?.length ?? 0;
useEffect(() => {
const batch = batchRef.current;
if (!analytics || !batch || batch.served || templates.length === 0) return;
void analytics.track({
name: 'task_template_list_served',
properties: getTaskTemplateListServedProperties({
recommendationBatchId: batch.id,
templates,
userInterestCount,
}),
});
batch.served = true;
}, [analytics, templates, userInterestCount]);
const removeTemplateFromList = useCallback(
(templateId: string) => {
mutate(
(current) =>
current
? { ...current, data: current.data.filter((tmpl) => tmpl.id !== templateId) }
: current,
{ revalidate: false },
);
},
[mutate],
);
const handleCreated = useCallback(
(templateId: string) => {
removeTemplateFromList(templateId);
},
[removeTemplateFromList],
);
const handleDismiss = useCallback(
async (templateId: string) => {
removeTemplateFromList(templateId);
try {
await taskTemplateService.dismiss(templateId);
} catch (error) {
console.error('[taskTemplate:dismiss]', error);
message.error(t('action.dismiss.error'));
mutate();
}
},
[message, mutate, removeTemplateFromList, t],
);
const requiredSources = useMemo(() => {
const sources = new Set<TaskTemplateSkillSource>();
for (const tmpl of templates) {
for (const s of tmpl.requiresSkills ?? []) sources.add(s.source);
for (const s of tmpl.optionalSkills ?? []) sources.add(s.source);
}
return sources;
}, [templates]);
const useFetchUserKlavisServers = useToolStore((s) => s.useFetchUserKlavisServers);
const useFetchLobehubSkillConnections = useToolStore((s) => s.useFetchLobehubSkillConnections);
useFetchUserKlavisServers(requiredSources.has('klavis'));
useFetchLobehubSkillConnections(requiredSources.has('lobehub'));
if (!swrEnabled) return { mode: 'hidden' };
if (!isInit || isLoading) return { mode: 'skeleton' };
if (templates.length === 0) return { mode: 'hidden' };
return {
mode: 'cards',
onCreated: handleCreated,
onDismiss: handleDismiss,
recommendationBatchId: recommendationBatchId ?? createRecommendationBatchId(),
templates,
userInterestCount,
};
}
export type { TaskTemplateRecommendationsUIState as DailyBriefRecommendationsUIState } from './useTaskTemplateRecommendationsUI';
export { useTaskTemplateRecommendationsUI as useDailyBriefRecommendationsUI } from './useTaskTemplateRecommendationsUI';
@@ -0,0 +1,144 @@
import type { RecommendedTaskTemplate, TaskTemplateSkillSource } from '@lobechat/const';
import { useAnalytics } from '@lobehub/analytics/react';
import { App } from 'antd';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import { taskTemplateService } from '@/services/taskTemplate';
import { useToolStore } from '@/store/tool';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
import { createRecommendationBatchId, getTaskTemplateListServedProperties } from './analytics';
import { useResolvedInterestKeys } from './useResolvedInterestKeys';
export type TaskTemplateRecommendationsUIState =
| { mode: 'hidden' }
| { mode: 'skeleton' }
| {
mode: 'cards';
onCreated: (templateId: string) => void;
onDismiss: (templateId: string) => void;
recommendationBatchId: string;
templates: RecommendedTaskTemplate[];
userInterestCount: number;
};
interface UseTaskTemplateRecommendationsUIOptions {
enabled?: boolean;
}
export function useTaskTemplateRecommendationsUI({
enabled = true,
}: UseTaskTemplateRecommendationsUIOptions = {}): TaskTemplateRecommendationsUIState {
const { t } = useTranslation('taskTemplate');
const { analytics } = useAnalytics();
const { message } = App.useApp();
const isLogin = useUserStore(authSelectors.isLogin);
const interestKeys = useResolvedInterestKeys();
const swrKey = interestKeys ? [...interestKeys].sort().join(',') : '';
const swrEnabled = enabled && isLogin && interestKeys !== null;
const batchRef = useRef<
| {
id: string;
served: boolean;
swrKey: string;
}
| undefined
>(undefined);
const { data, isLoading, mutate } = useSWR(
swrEnabled ? ['taskTemplate.listDailyRecommend', swrKey] : null,
async () => taskTemplateService.listDailyRecommend(interestKeys ?? []),
{ revalidateOnFocus: false, revalidateOnReconnect: false },
);
const templates = useMemo(() => data?.data ?? [], [data]);
if (templates.length > 0 && batchRef.current?.swrKey !== swrKey) {
batchRef.current = {
id: createRecommendationBatchId(),
served: false,
swrKey,
};
}
const recommendationBatchId = batchRef.current?.id;
const userInterestCount = interestKeys?.length ?? 0;
useEffect(() => {
const batch = batchRef.current;
if (!analytics || !batch || batch.served || templates.length === 0) return;
void analytics.track({
name: 'task_template_list_served',
properties: getTaskTemplateListServedProperties({
recommendationBatchId: batch.id,
templates,
userInterestCount,
}),
});
batch.served = true;
}, [analytics, templates, userInterestCount]);
const removeTemplateFromList = useCallback(
(templateId: string) => {
mutate(
(current) =>
current
? { ...current, data: current.data.filter((tmpl) => tmpl.id !== templateId) }
: current,
{ revalidate: false },
);
},
[mutate],
);
const handleCreated = useCallback(
(templateId: string) => {
removeTemplateFromList(templateId);
},
[removeTemplateFromList],
);
const handleDismiss = useCallback(
async (templateId: string) => {
removeTemplateFromList(templateId);
try {
await taskTemplateService.dismiss(templateId);
} catch (error) {
console.error('[taskTemplate:dismiss]', error);
message.error(t('action.dismiss.error'));
mutate();
}
},
[message, mutate, removeTemplateFromList, t],
);
const requiredSources = useMemo(() => {
const sources = new Set<TaskTemplateSkillSource>();
for (const tmpl of templates) {
for (const s of tmpl.requiresSkills ?? []) sources.add(s.source);
for (const s of tmpl.optionalSkills ?? []) sources.add(s.source);
}
return sources;
}, [templates]);
const useFetchUserKlavisServers = useToolStore((s) => s.useFetchUserKlavisServers);
const useFetchLobehubSkillConnections = useToolStore((s) => s.useFetchLobehubSkillConnections);
useFetchUserKlavisServers(requiredSources.has('klavis'));
useFetchLobehubSkillConnections(requiredSources.has('lobehub'));
if (!swrEnabled) return { mode: 'hidden' };
if (isLoading) return { mode: 'skeleton' };
if (templates.length === 0) return { mode: 'hidden' };
return {
mode: 'cards',
onCreated: handleCreated,
onDismiss: handleDismiss,
recommendationBatchId: recommendationBatchId ?? createRecommendationBatchId(),
templates,
userInterestCount,
};
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default {
'card.templateTag': 'Template',
'section.title': 'Try these scheduled tasks',
'section.title': 'Try these tasks',
// ===== content-creation =====
'daily-topic-pick.title': 'Daily topic radar',
+17 -6
View File
@@ -17,13 +17,17 @@ const UTC_DAY_1 = new Date('2026-04-24T10:00:00Z');
const UTC_DAY_2 = new Date('2026-04-25T10:00:00Z');
describe('TaskTemplateService.listDailyRecommend', () => {
it('returns RECOMMEND_COUNT items when user has matching interests', async () => {
it('returns 10 items when user has matching interests', async () => {
const service = new TaskTemplateService('user-1');
const picked = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
expect(picked).toHaveLength(RECOMMEND_COUNT);
expect(picked.every((p) => p.source === 'matched')).toBe(true);
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
expect(RECOMMEND_COUNT).toBe(10);
expect(picked).toHaveLength(10);
const codingMatches = taskTemplates.filter(
(t) => t.interests.includes('coding') && isTemplateSkillSourceEligible(t),
);
const matched = picked.filter((p) => p.source === 'matched');
expect(matched).toHaveLength(Math.min(RECOMMEND_COUNT, codingMatches.length));
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
});
@@ -63,13 +67,20 @@ describe('TaskTemplateService.listDailyRecommend', () => {
it('falls back to fallback categories when user has no interests', async () => {
const service = new TaskTemplateService('user-1');
const picked = await service.listDailyRecommend([], { now: UTC_DAY_1 });
const fallbackCategoryCount = taskTemplates.filter(
(t) =>
TASK_TEMPLATE_FALLBACK_CATEGORIES.includes(t.category) && isTemplateSkillSourceEligible(t),
).length;
expect(picked).toHaveLength(RECOMMEND_COUNT);
for (const p of picked) {
for (const p of picked.slice(0, fallbackCategoryCount)) {
expect(taskTemplates.some((t) => t.id === p.id)).toBe(true);
expect(p.source).toBe('fallback');
expect(p.fallbackPool).toBe('preferred_category');
}
expect(
picked.slice(fallbackCategoryCount).every((p) => p.fallbackPool === 'all_candidates'),
).toBe(true);
});
it('marks all-candidate fallback when preferred fallback categories are exhausted', async () => {
@@ -98,7 +109,7 @@ describe('TaskTemplateService.listDailyRecommend', () => {
it('unrecognized interest strings fall back to non-matched pool', async () => {
const service = new TaskTemplateService('user-1');
// Freeform custom input won't match any template's interests — should still return 3 picks
// Freeform custom input won't match any template's interests — should still return 10 picks.
const picked = await service.listDailyRecommend(['my special hobby'], { now: UTC_DAY_1 });
expect(picked).toHaveLength(RECOMMEND_COUNT);
+1 -1
View File
@@ -10,7 +10,7 @@ import { TASK_TEMPLATE_FALLBACK_CATEGORIES, taskTemplates } from '@lobechat/cons
import { klavisEnv } from '@/config/klavis';
import { appEnv } from '@/envs/app';
export const RECOMMEND_COUNT = 3;
export const RECOMMEND_COUNT = 10;
export const ENABLED_SKILL_SOURCES: ReadonlySet<TaskTemplateSkillSource> = (() => {
const sources = new Set<TaskTemplateSkillSource>();