cc-haha/desktop/src/components/chat/SessionTaskBar.tsx
程序员阿江(Relakkes) 16d716e773 fix: keep completed task lists from leaking into the next turn
Completed desktop task bars were only being dismissed locally, which let the
persisted task list resurface on refresh or bleed into the next user turn.
This wires the existing server-side reset path into the desktop stores and
session flow so a finished task cycle is summarized once, cleared locally,
and removed remotely before the next round starts.

Constraint: Existing task persistence already lives behind the server task-list API and must stay compatible with persisted JSON task files
Rejected: Only hide the completed task bar in UI state | left stale persisted tasks behind and reintroduced them on reload
Rejected: Clear desktop state without a task summary | dropped useful completion context from the chat transcript
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep desktop task dismissal and task-list persistence behavior aligned; do not reintroduce local-only clearing without covering reload and next-turn flows
Tested: `cd desktop && bun run lint`
Tested: `cd desktop && bun run test src/stores/cliTaskStore.test.ts src/stores/chatStore.test.ts src/components/chat/SessionTaskBar.test.tsx src/components/chat/ComputerUsePermissionModal.test.tsx`
Tested: `bun test src/server/__tests__/e2e/business-flow.test.ts --test-name-pattern "Task Lists API"`
Not-tested: Full `bun test src/server/__tests__/e2e/business-flow.test.ts` suite still has unrelated pre-existing failures in Models and Sessions sections
2026-04-21 00:46:29 +08:00

160 lines
5.6 KiB
TypeScript

import { useCLITaskStore } from '../../stores/cliTaskStore'
import { useTranslation } from '../../i18n'
import type { CLITask } from '../../types/cliTask'
const statusConfig = {
pending: {
icon: 'radio_button_unchecked',
color: 'var(--color-text-tertiary)',
label: 'pending',
},
in_progress: {
icon: 'pending',
color: 'var(--color-warning)',
label: 'active',
},
completed: {
icon: 'check_circle',
color: 'var(--color-success)',
label: 'done',
},
} as const
export function SessionTaskBar() {
const {
tasks,
expanded,
toggleExpanded,
completedAndDismissed,
resetCompletedTasks,
} = useCLITaskStore()
const t = useTranslation()
if (tasks.length === 0) return null
// Don't show sticky bar if tasks were completed and the user already continued chatting
const allCompleted = tasks.every((tk) => tk.status === 'completed')
if (allCompleted && completedAndDismissed) return null
const completedCount = tasks.filter((tk) => tk.status === 'completed').length
const totalCount = tasks.length
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
return (
<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 */}
<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"
>
<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={() => { void resetCompletedTasks() }}
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 && (
<div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-outline-variant)]/20">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
)}
</div>
</div>
)
}
function TaskItem({ task }: { task: CLITask }) {
const config = statusConfig[task.status]
return (
<div className="flex items-start gap-2 py-1.5 px-1 rounded-md">
<span
className="material-symbols-outlined text-[16px] mt-px shrink-0"
style={{ color: config.color, fontVariationSettings: "'FILL' 1" }}
>
{config.icon}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono text-[var(--color-text-tertiary)]">
#{task.id}
</span>
<span className={`text-xs ${
task.status === 'completed'
? 'text-[var(--color-text-tertiary)] line-through'
: 'text-[var(--color-text-primary)]'
}`}>
{task.subject}
</span>
</div>
{task.status === 'in_progress' && task.activeForm && (
<div className="flex items-center gap-1 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse" />
<span className="text-[10px] text-[var(--color-warning)]">
{task.activeForm}
</span>
</div>
)}
{task.owner && (
<span className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5 inline-flex items-center gap-0.5">
<span className="material-symbols-outlined text-[10px]">person</span>
{task.owner}
</span>
)}
</div>
</div>
)
}