mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: allow dismissing completed session tasks
This commit is contained in:
parent
fb011588f9
commit
3735ee803f
99
desktop/src/components/chat/SessionTaskBar.test.tsx
Normal file
99
desktop/src/components/chat/SessionTaskBar.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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 && (
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -392,6 +392,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// ─── Tasks ──────────────────────────────────────
|
||||
'tasks.title': '任务',
|
||||
'tasks.completed': '已完成的任务',
|
||||
'tasks.dismissCompleted': '隐藏已完成任务',
|
||||
'tasks.newTask': '+ 新建任务',
|
||||
'tasks.emptyTitle': '暂无定时任务。',
|
||||
'tasks.emptyDesc': '创建定时任务来自动化你的工程工作流。',
|
||||
|
||||
@ -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: () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user