fix: allow dismissing completed session tasks

This commit is contained in:
Relakkes Yang 2026-04-19 18:27:21 +08:00
parent fb011588f9
commit 3735ee803f
5 changed files with 233 additions and 58 deletions

View File

@ -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<string, string> = {
'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(<SessionTaskBar />)
})
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(<SessionTaskBar />)
})
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(<SessionTaskBar />)
})
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()
})
})

View File

@ -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() {
<div className="shrink-0 px-8">
<div className="mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
{/* Header — always visible, clickable to toggle */}
<button
onClick={toggleExpanded}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--color-surface-container-low)] transition-colors bg-[var(--color-surface-container)]"
>
<div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
<span
className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]"
>
checklist
</span>
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{t('tasks.title')}
</span>
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPercent}%`,
backgroundColor: completedCount === totalCount
? 'var(--color-success)'
: 'var(--color-brand)',
}}
/>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
{completedCount}/{totalCount}
</span>
<span
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
<div className="flex items-center gap-2 bg-[var(--color-surface-container)] px-2 py-1.5">
<button
type="button"
onClick={toggleExpanded}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-2 py-1 hover:bg-[var(--color-surface-container-low)] transition-colors"
>
expand_less
</span>
</button>
<div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
<span
className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]"
>
checklist
</span>
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{t('tasks.title')}
</span>
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPercent}%`,
backgroundColor: completedCount === totalCount
? 'var(--color-success)'
: 'var(--color-brand)',
}}
/>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
{completedCount}/{totalCount}
</span>
<span
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
expand_less
</span>
</button>
{allCompleted && (
<button
type="button"
aria-label={t('tasks.dismissCompleted')}
onClick={markCompletedAndDismissed}
className="flex shrink-0 items-center justify-center rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] transition-colors"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
)}
</div>
{/* Expanded task list */}
{expanded && (

View File

@ -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.',

View File

@ -392,6 +392,7 @@ export const zh: Record<TranslationKey, string> = {
// ─── Tasks ──────────────────────────────────────
'tasks.title': '任务',
'tasks.completed': '已完成的任务',
'tasks.dismissCompleted': '隐藏已完成任务',
'tasks.newTask': '+ 新建任务',
'tasks.emptyTitle': '暂无定时任务。',
'tasks.emptyDesc': '创建定时任务来自动化你的工程工作流。',

View File

@ -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<void>
@ -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<CLITaskStore>((set, get) => ({
sessionId: null,
tasks: [],
expanded: false,
completedAndDismissed: false,
dismissedCompletionKey: null,
fetchSessionTasks: async (sessionId) => {
set({ sessionId })
@ -45,12 +88,15 @@ export const useCLITaskStore = create<CLITaskStore>((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<CLITaskStore>((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<CLITaskStore>((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: () => {