diff --git a/desktop/src/components/chat/SessionTaskBar.test.tsx b/desktop/src/components/chat/SessionTaskBar.test.tsx new file mode 100644 index 00000000..3b7f6bf7 --- /dev/null +++ b/desktop/src/components/chat/SessionTaskBar.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { SessionTaskBar } from './SessionTaskBar' +import { useCLITaskStore } from '../../stores/cliTaskStore' + +vi.mock('../../i18n', () => ({ + useTranslation: () => (key: string) => { + const translations: Record = { + 'tasks.title': 'Tasks', + 'tasks.dismissCompleted': 'Hide completed tasks', + } + + return translations[key] ?? key + }, +})) + +describe('SessionTaskBar', () => { + beforeEach(() => { + useCLITaskStore.setState({ + sessionId: 'session-1', + tasks: [], + expanded: false, + completedAndDismissed: false, + dismissedCompletionKey: null, + }) + }) + + afterEach(() => { + useCLITaskStore.getState().clearTasks() + }) + + it('only shows the dismiss button once every task is completed', () => { + act(() => { + useCLITaskStore.getState().setTasksFromTodos([ + { content: 'first', status: 'completed' }, + { content: 'second', status: 'in_progress', activeForm: 'working' }, + ]) + }) + + act(() => { + render() + }) + + expect(screen.getByText('Tasks')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Hide completed tasks' })).toBeNull() + }) + + it('hides the bar after dismissing a completed task set', () => { + act(() => { + useCLITaskStore.getState().setTasksFromTodos([ + { content: 'first', status: 'completed' }, + { content: 'second', status: 'completed' }, + ]) + }) + + act(() => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Hide completed tasks' })) + + expect(screen.queryByText('Tasks')).toBeNull() + expect(useCLITaskStore.getState().completedAndDismissed).toBe(true) + }) + + it('shows the bar again for a new task cycle after a previous completed set was dismissed', () => { + act(() => { + useCLITaskStore.getState().setTasksFromTodos([ + { content: 'first', status: 'completed' }, + ]) + }) + + act(() => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Hide completed tasks' })) + expect(screen.queryByText('Tasks')).toBeNull() + + act(() => { + useCLITaskStore.getState().setTasksFromTodos([ + { content: 'next task', status: 'in_progress', activeForm: 'running next task' }, + ]) + }) + + expect(screen.getByText('Tasks')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Hide completed tasks' })).toBeNull() + + act(() => { + useCLITaskStore.getState().setTasksFromTodos([ + { content: 'next task', status: 'completed' }, + ]) + }) + + expect(screen.getByText('Tasks')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Hide completed tasks' })).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/chat/SessionTaskBar.tsx b/desktop/src/components/chat/SessionTaskBar.tsx index 62778b08..2bc9b05d 100644 --- a/desktop/src/components/chat/SessionTaskBar.tsx +++ b/desktop/src/components/chat/SessionTaskBar.tsx @@ -21,7 +21,13 @@ const statusConfig = { } as const export function SessionTaskBar() { - const { tasks, expanded, toggleExpanded, completedAndDismissed } = useCLITaskStore() + const { + tasks, + expanded, + toggleExpanded, + completedAndDismissed, + markCompletedAndDismissed, + } = useCLITaskStore() const t = useTranslation() if (tasks.length === 0) return null @@ -38,46 +44,60 @@ export function SessionTaskBar() {
{/* Header — always visible, clickable to toggle */} - +
+ + checklist + +
+ + + {t('tasks.title')} + + + {/* Progress bar */} +
+
+
+ + + {completedCount}/{totalCount} + + + + expand_less + + + + {allCompleted && ( + + )} +
{/* Expanded task list */} {expanded && ( diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index a1e935a5..d1cf298b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -390,6 +390,7 @@ export const en = { // ─── Tasks ────────────────────────────────────── 'tasks.title': 'Tasks', 'tasks.completed': 'Tasks completed', + 'tasks.dismissCompleted': 'Hide completed tasks', 'tasks.newTask': '+ New task', 'tasks.emptyTitle': 'No scheduled tasks yet.', 'tasks.emptyDesc': 'Create a recurring task to automate your engineering workflows.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 9590a1b2..bd5f83b8 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -392,6 +392,7 @@ export const zh: Record = { // ─── Tasks ────────────────────────────────────── 'tasks.title': '任务', 'tasks.completed': '已完成的任务', + 'tasks.dismissCompleted': '隐藏已完成任务', 'tasks.newTask': '+ 新建任务', 'tasks.emptyTitle': '暂无定时任务。', 'tasks.emptyDesc': '创建定时任务来自动化你的工程工作流。', diff --git a/desktop/src/stores/cliTaskStore.ts b/desktop/src/stores/cliTaskStore.ts index c24fbfb0..960a38a7 100644 --- a/desktop/src/stores/cliTaskStore.ts +++ b/desktop/src/stores/cliTaskStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { cliTasksApi } from '../api/cliTasks' -import type { CLITask } from '../types/cliTask' +import type { CLITask, TaskStatus } from '../types/cliTask' type TodoItem = { content: string @@ -18,6 +18,8 @@ type CLITaskStore = { /** True when all tasks completed and the user already continued chatting. * Set during history load so the sticky bar is suppressed on page refresh. */ completedAndDismissed: boolean + /** Snapshot of the completed task set that was dismissed */ + dismissedCompletionKey: string | null /** Fetch tasks for a given session (uses sessionId as taskListId) */ fetchSessionTasks: (sessionId: string) => Promise @@ -33,11 +35,52 @@ type CLITaskStore = { toggleExpanded: () => void } +function buildCompletedTaskKey(tasks: CLITask[]): string | null { + if (tasks.length === 0 || tasks.some((task) => task.status !== 'completed')) return null + + return tasks + .map((task) => [ + task.taskListId, + task.id, + task.subject, + task.status, + task.activeForm ?? '', + task.owner ?? '', + ].join('::')) + .join('|') +} + +function resolveDismissState(tasks: CLITask[], dismissedCompletionKey: string | null) { + const completionKey = buildCompletedTaskKey(tasks) + const keepDismissed = completionKey !== null && completionKey === dismissedCompletionKey + + return { + completedAndDismissed: keepDismissed, + dismissedCompletionKey: keepDismissed ? completionKey : null, + } +} + +function mapTodosToTasks(todos: TodoItem[], sessionId: string | null): CLITask[] { + return todos.map((todo, index) => ({ + id: String(index + 1), + subject: todo.content, + description: '', + activeForm: todo.activeForm, + status: (['pending', 'in_progress', 'completed'].includes(todo.status) + ? todo.status + : 'pending') as TaskStatus, + blocks: [], + blockedBy: [], + taskListId: sessionId || '', + })) +} + export const useCLITaskStore = create((set, get) => ({ sessionId: null, tasks: [], expanded: false, completedAndDismissed: false, + dismissedCompletionKey: null, fetchSessionTasks: async (sessionId) => { set({ sessionId }) @@ -45,12 +88,15 @@ export const useCLITaskStore = create((set, get) => ({ const { tasks } = await cliTasksApi.getTasksForList(sessionId) // Only update if still tracking the same session if (get().sessionId === sessionId) { - set({ tasks }) + set((state) => ({ + tasks, + ...resolveDismissState(tasks, state.dismissedCompletionKey), + })) } } catch { // No tasks for this session — that's fine if (get().sessionId === sessionId) { - set({ tasks: [] }) + set({ tasks: [], completedAndDismissed: false, dismissedCompletionKey: null }) } } }, @@ -61,7 +107,10 @@ export const useCLITaskStore = create((set, get) => ({ try { const { tasks } = await cliTasksApi.getTasksForList(sessionId) if (get().sessionId === sessionId) { - set({ tasks, completedAndDismissed: false }) + set((state) => ({ + tasks, + ...resolveDismissState(tasks, state.dismissedCompletionKey), + })) } } catch { // ignore @@ -69,27 +118,32 @@ export const useCLITaskStore = create((set, get) => ({ }, setTasksFromTodos: (todos) => { - const tasks: CLITask[] = todos.map((todo, index) => ({ - id: String(index + 1), - subject: todo.content, - description: '', - activeForm: todo.activeForm, - status: (['pending', 'in_progress', 'completed'].includes(todo.status) - ? todo.status - : 'pending') as CLITask['status'], - blocks: [], - blockedBy: [], - taskListId: get().sessionId || '', + const tasks = mapTodosToTasks(todos, get().sessionId) + set((state) => ({ + tasks, + ...resolveDismissState(tasks, state.dismissedCompletionKey), })) - set({ tasks, completedAndDismissed: false }) }, markCompletedAndDismissed: () => { - set({ completedAndDismissed: true }) + const completionKey = buildCompletedTaskKey(get().tasks) + if (!completionKey) return + + set({ + completedAndDismissed: true, + dismissedCompletionKey: completionKey, + expanded: false, + }) }, clearTasks: () => { - set({ sessionId: null, tasks: [], completedAndDismissed: false, expanded: false }) + set({ + sessionId: null, + tasks: [], + completedAndDismissed: false, + dismissedCompletionKey: null, + expanded: false, + }) }, toggleExpanded: () => {