From 5a559ff069796b19f026774bbcbb41036dceeffc Mon Sep 17 00:00:00 2001 From: ncnthien Date: Fri, 22 May 2026 15:04:24 +0700 Subject: [PATCH 1/2] feat: [ENG-2784] add WebUI cancel button to task list rows and detail header --- src/webui/features/tasks/api/cancel-task.ts | 29 ++++++++ .../tasks/components/task-detail-header.tsx | 28 ++++++-- .../tasks/components/task-detail-view.tsx | 13 +++- .../tasks/components/task-list-table.tsx | 37 +++++++++- .../tasks/components/task-list-view.tsx | 32 +++++++++ .../features/tasks/utils/row-action-kind.ts | 9 +++ .../features/tasks/api/cancel-task.test.ts | 69 +++++++++++++++++++ .../tasks/utils/row-action-kind.test.ts | 25 +++++++ 8 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 src/webui/features/tasks/api/cancel-task.ts create mode 100644 src/webui/features/tasks/utils/row-action-kind.ts create mode 100644 test/unit/webui/features/tasks/api/cancel-task.test.ts create mode 100644 test/unit/webui/features/tasks/utils/row-action-kind.test.ts diff --git a/src/webui/features/tasks/api/cancel-task.ts b/src/webui/features/tasks/api/cancel-task.ts new file mode 100644 index 000000000..174414484 --- /dev/null +++ b/src/webui/features/tasks/api/cancel-task.ts @@ -0,0 +1,29 @@ +import {useMutation} from '@tanstack/react-query' + +import type {MutationConfig} from '../../../lib/react-query' + +import { + type TaskCancelRequest, + type TaskCancelResponse, + TaskEvents, +} from '../../../../shared/transport/events/task-events' +import {useTransportStore} from '../../../stores/transport-store' + +export const cancelTask = async (payload: TaskCancelRequest): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) throw new Error('Not connected') + + const response = await apiClient.request(TaskEvents.CANCEL, payload) + if (!response.success) throw new Error(response.error ?? 'Cancel failed') + return response +} + +type UseCancelTaskOptions = { + mutationConfig?: MutationConfig +} + +export const useCancelTask = ({mutationConfig}: UseCancelTaskOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: cancelTask, + }) diff --git a/src/webui/features/tasks/components/task-detail-header.tsx b/src/webui/features/tasks/components/task-detail-header.tsx index 293118684..348949e25 100644 --- a/src/webui/features/tasks/components/task-detail-header.tsx +++ b/src/webui/features/tasks/components/task-detail-header.tsx @@ -1,6 +1,7 @@ import {Button} from '@campfirein/byterover-packages/components/button' import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' import {cn} from '@campfirein/byterover-packages/lib/utils' +import {CircleStop} from 'lucide-react' import {toast} from 'sonner' import type {StoredTask} from '../types/stored-task' @@ -18,8 +19,16 @@ const STATUS_VERB: Record = { started: 'started', } -export function DetailHeader({now, task}: {now: number; task: StoredTask}) { +interface DetailHeaderProps { + cancelling: boolean + now: number + onCancel: (taskId: string) => void + task: StoredTask +} + +export function DetailHeader({cancelling, now, onCancel, task}: DetailHeaderProps) { const isTerminal = isTerminalStatus(task.status) + const isActive = isActiveStatus(task.status) const elapsed = elapsedMs(task, now) const referenceTime = task.startedAt ?? task.createdAt const verb = STATUS_VERB[task.status] @@ -42,11 +51,22 @@ export function DetailHeader({now, task}: {now: number; task: StoredTask}) { {verb} {formatRelative(referenceTime, now)} ago - + {elapsedLabel} {formatDuration(elapsed)} + {isActive && ( + + )} ) diff --git a/src/webui/features/tasks/components/task-detail-view.tsx b/src/webui/features/tasks/components/task-detail-view.tsx index 0a07bb1ba..b00a762a4 100644 --- a/src/webui/features/tasks/components/task-detail-view.tsx +++ b/src/webui/features/tasks/components/task-detail-view.tsx @@ -1,9 +1,12 @@ import type {ComponentRef} from 'react' +import {toast} from 'sonner' + import type {StoredTask} from '../types/stored-task' import {TourTaskBanner, TourTaskContinueCta} from '../../onboarding/components/tour-task-banner' import {useOnboardingStore} from '../../onboarding/stores/onboarding-store' +import {useCancelTask} from '../api/cancel-task' import {useGetTaskDetail} from '../api/get-task' import {useStickToBottom} from '../hooks/use-stick-to-bottom' import {useTickingNow} from '../hooks/use-ticking-now' @@ -41,6 +44,14 @@ export function TaskDetailView({taskId}: TaskDetailViewProps) { const tourTaskId = useOnboardingStore((s) => s.tourTaskId) const isTourTask = tourTaskId === taskId + const cancelMutation = useCancelTask() + const handleCancel = (id: string) => { + cancelMutation.mutate( + {taskId: id}, + {onError: (err) => toast.error(err.message)}, + ) + } + const lastReasoning = task?.reasoningContents?.at(-1) const {onScroll, ref: scrollRef} = useStickToBottom>( [ @@ -80,7 +91,7 @@ export function TaskDetailView({taskId}: TaskDetailViewProps) { return (
- +
diff --git a/src/webui/features/tasks/components/task-list-table.tsx b/src/webui/features/tasks/components/task-list-table.tsx index e1ea597d7..03d7e8f60 100644 --- a/src/webui/features/tasks/components/task-list-table.tsx +++ b/src/webui/features/tasks/components/task-list-table.tsx @@ -10,7 +10,7 @@ import { } from '@campfirein/byterover-packages/components/table' import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' import {cn} from '@campfirein/byterover-packages/lib/utils' -import {Trash2} from 'lucide-react' +import {CircleStop, Trash2} from 'lucide-react' import type {StatusFilter} from '../stores/task-store' import type {StoredTask} from '../types/stored-task' @@ -19,6 +19,7 @@ import {getCurrentActivity} from '../utils/current-activity' import {formatProviderModel} from '../utils/format-provider-model' import {formatDuration, formatRelative, formatTimeOfDay, shortTaskId} from '../utils/format-time' import {isInterrupted} from '../utils/is-interrupted' +import {rowActionKind} from '../utils/row-action-kind' import {displayTaskType, isTerminalStatus} from '../utils/task-status' import {StatusPill} from './status-pill' import {NoMatchState} from './task-list-empty' @@ -46,8 +47,10 @@ function durationOf(task: StoredTask, now: number): string { interface TaskTableProps { allSelected: boolean + cancellingIds: Set filtered: StoredTask[] now: number + onCancel: (taskId: string) => void onClearSearch: () => void onDelete: (taskId: string) => void onRowClick: (taskId: string) => void @@ -61,8 +64,10 @@ interface TaskTableProps { export function TaskTable({ allSelected, + cancellingIds, filtered, now, + onCancel, onClearSearch, onDelete, onRowClick, @@ -100,9 +105,11 @@ export function TaskTable({ ) : ( filtered.map((task) => ( void onDelete: (taskId: string) => void onRowClick: (taskId: string) => void onToggleSelect: (taskId: string) => void @@ -137,6 +148,7 @@ function TaskRow({ const isRunning = !terminal const interrupted = isInterrupted(task) const activity = getCurrentActivity(task) + const actionKind = rowActionKind(task.status) const row = ( event.stopPropagation()}> - {terminal && onDelete(task.taskId)} />} + {actionKind === 'delete' ? ( + onDelete(task.taskId)} /> + ) : ( + onCancel(task.taskId)} /> + )} ) @@ -231,7 +247,7 @@ function ProviderChip({model, provider, providerName}: {model?: string; provider ) } -function RowAction({onClick}: {onClick: () => void}) { +function DeleteRowAction({onClick}: {onClick: () => void}) { return ( + ) +} + function Checkbox({checked, onChange}: {checked: boolean; onChange: () => void}) { return ( >(new Set()) + const [cancellingIds, setCancellingIds] = useState>(new Set()) const [composer, setComposer] = useState<{ initialContent?: string initialType?: ComposerType @@ -239,6 +242,33 @@ export function TaskListView() { ) } + const handleCancel = (taskId: string) => { + setCancellingIds((prev) => { + const next = new Set(prev) + next.add(taskId) + return next + }) + const clear = () => { + setCancellingIds((prev) => { + if (!prev.has(taskId)) return prev + const next = new Set(prev) + next.delete(taskId) + return next + }) + } + + cancelMutation.mutate( + {taskId}, + { + onError(err) { + toast.error(err.message) + clear() + }, + onSuccess: clear, + }, + ) + } + const deleteSelected = () => { const eligibleIds = [...selectedIds].filter((id) => { const task = taskMap.get(id) @@ -357,8 +387,10 @@ export function TaskListView() { ) : ( setSearchQuery('')} onDelete={handleDelete} onRowClick={openTask} diff --git a/src/webui/features/tasks/utils/row-action-kind.ts b/src/webui/features/tasks/utils/row-action-kind.ts new file mode 100644 index 000000000..2b0d64228 --- /dev/null +++ b/src/webui/features/tasks/utils/row-action-kind.ts @@ -0,0 +1,9 @@ +import type {TaskListItemStatus} from '../../../../shared/transport/events/task-events' + +import {isTerminalStatus} from './task-status' + +export type RowActionKind = 'cancel' | 'delete' + +export function rowActionKind(status: TaskListItemStatus): RowActionKind { + return isTerminalStatus(status) ? 'delete' : 'cancel' +} diff --git a/test/unit/webui/features/tasks/api/cancel-task.test.ts b/test/unit/webui/features/tasks/api/cancel-task.test.ts new file mode 100644 index 000000000..4260c86fb --- /dev/null +++ b/test/unit/webui/features/tasks/api/cancel-task.test.ts @@ -0,0 +1,69 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {BrvApiClient} from '../../../../../../src/webui/lib/api-client.js' + +import {TaskEvents} from '../../../../../../src/shared/transport/events/task-events.js' +import {cancelTask} from '../../../../../../src/webui/features/tasks/api/cancel-task.js' +import {useTransportStore} from '../../../../../../src/webui/stores/transport-store.js' + +describe('cancelTask', () => { + let sandbox: SinonSandbox + let request: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + request = sandbox.stub() + useTransportStore.setState({ + apiClient: {on: sandbox.stub(), request} as unknown as BrvApiClient, + }) + }) + + afterEach(() => { + sandbox.restore() + useTransportStore.setState({apiClient: null}) + }) + + it('emits task:cancel with the taskId payload', async () => { + request.resolves({success: true}) + await cancelTask({taskId: 'tsk-1'}) + expect(request.firstCall.args[0]).to.equal(TaskEvents.CANCEL) + expect(request.firstCall.args[1]).to.deep.equal({taskId: 'tsk-1'}) + }) + + it('resolves with the daemon response on success', async () => { + request.resolves({success: true}) + const result = await cancelTask({taskId: 'tsk-1'}) + expect(result).to.deep.equal({success: true}) + }) + + it('throws when the daemon returns success: false', async () => { + request.resolves({error: 'Task not found', success: false}) + try { + await cancelTask({taskId: 'tsk-1'}) + expect.fail('expected to throw') + } catch (error) { + expect((error as Error).message).to.equal('Task not found') + } + }) + + it('falls back to "Cancel failed" when success: false has no error string', async () => { + request.resolves({success: false}) + try { + await cancelTask({taskId: 'tsk-1'}) + expect.fail('expected to throw') + } catch (error) { + expect((error as Error).message).to.equal('Cancel failed') + } + }) + + it('throws when not connected to the daemon', async () => { + useTransportStore.setState({apiClient: null}) + try { + await cancelTask({taskId: 'tsk-1'}) + expect.fail('expected to throw') + } catch (error) { + expect((error as Error).message).to.equal('Not connected') + } + }) +}) diff --git a/test/unit/webui/features/tasks/utils/row-action-kind.test.ts b/test/unit/webui/features/tasks/utils/row-action-kind.test.ts new file mode 100644 index 000000000..62624e612 --- /dev/null +++ b/test/unit/webui/features/tasks/utils/row-action-kind.test.ts @@ -0,0 +1,25 @@ +import {expect} from 'chai' + +import {rowActionKind} from '../../../../../../src/webui/features/tasks/utils/row-action-kind.js' + +describe('rowActionKind', () => { + it('returns "cancel" for created tasks (running)', () => { + expect(rowActionKind('created')).to.equal('cancel') + }) + + it('returns "cancel" for started tasks (running)', () => { + expect(rowActionKind('started')).to.equal('cancel') + }) + + it('returns "delete" for completed tasks (terminal)', () => { + expect(rowActionKind('completed')).to.equal('delete') + }) + + it('returns "delete" for error tasks (terminal)', () => { + expect(rowActionKind('error')).to.equal('delete') + }) + + it('returns "delete" for cancelled tasks (terminal)', () => { + expect(rowActionKind('cancelled')).to.equal('delete') + }) +}) From 98c0eb36a11f4aea03bfb75f9723f6d724544d5d Mon Sep 17 00:00:00 2001 From: ncnthien Date: Fri, 22 May 2026 15:17:21 +0700 Subject: [PATCH 2/2] feat: [ENG-2784] unify cancel state across list and detail, add pending feedback --- .../tasks/components/task-detail-header.tsx | 6 +++--- .../tasks/components/task-detail-view.tsx | 17 ++++------------- .../tasks/components/task-list-table.tsx | 12 ++++++------ .../tasks/components/task-list-view.tsx | 8 +++++++- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/webui/features/tasks/components/task-detail-header.tsx b/src/webui/features/tasks/components/task-detail-header.tsx index 348949e25..360b74a69 100644 --- a/src/webui/features/tasks/components/task-detail-header.tsx +++ b/src/webui/features/tasks/components/task-detail-header.tsx @@ -1,7 +1,7 @@ import {Button} from '@campfirein/byterover-packages/components/button' import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' import {cn} from '@campfirein/byterover-packages/lib/utils' -import {CircleStop} from 'lucide-react' +import {CircleStop, LoaderCircle} from 'lucide-react' import {toast} from 'sonner' import type {StoredTask} from '../types/stored-task' @@ -63,8 +63,8 @@ export function DetailHeader({cancelling, now, onCancel, task}: DetailHeaderProp size="xs" variant="outline" > - - Cancel + {cancelling ? : } + {cancelling ? 'Cancelling…' : 'Cancel'} )}
diff --git a/src/webui/features/tasks/components/task-detail-view.tsx b/src/webui/features/tasks/components/task-detail-view.tsx index b00a762a4..9f21f07b9 100644 --- a/src/webui/features/tasks/components/task-detail-view.tsx +++ b/src/webui/features/tasks/components/task-detail-view.tsx @@ -1,12 +1,9 @@ import type {ComponentRef} from 'react' -import {toast} from 'sonner' - import type {StoredTask} from '../types/stored-task' import {TourTaskBanner, TourTaskContinueCta} from '../../onboarding/components/tour-task-banner' import {useOnboardingStore} from '../../onboarding/stores/onboarding-store' -import {useCancelTask} from '../api/cancel-task' import {useGetTaskDetail} from '../api/get-task' import {useStickToBottom} from '../hooks/use-stick-to-bottom' import {useTickingNow} from '../hooks/use-ticking-now' @@ -18,6 +15,8 @@ import {DetailHeader} from './task-detail-header' import {ErrorSection, InputSection, LiveStreamSection, NotFound, ResultSection} from './task-detail-sections' interface TaskDetailViewProps { + cancelling: boolean + onCancel: (taskId: string) => void taskId: string } @@ -29,7 +28,7 @@ function hasRichDetail(task: StoredTask | undefined): boolean { } // eslint-disable-next-line complexity -export function TaskDetailView({taskId}: TaskDetailViewProps) { +export function TaskDetailView({cancelling, onCancel, taskId}: TaskDetailViewProps) { const storeTask = useTaskById(taskId) const isLiveInStore = storeTask !== undefined && isActiveStatus(storeTask.status) const needsFetch = !hasRichDetail(storeTask) && !isLiveInStore @@ -44,14 +43,6 @@ export function TaskDetailView({taskId}: TaskDetailViewProps) { const tourTaskId = useOnboardingStore((s) => s.tourTaskId) const isTourTask = tourTaskId === taskId - const cancelMutation = useCancelTask() - const handleCancel = (id: string) => { - cancelMutation.mutate( - {taskId: id}, - {onError: (err) => toast.error(err.message)}, - ) - } - const lastReasoning = task?.reasoningContents?.at(-1) const {onScroll, ref: scrollRef} = useStickToBottom>( [ @@ -91,7 +82,7 @@ export function TaskDetailView({taskId}: TaskDetailViewProps) { return (
- +
diff --git a/src/webui/features/tasks/components/task-list-table.tsx b/src/webui/features/tasks/components/task-list-table.tsx index 03d7e8f60..92991c0b6 100644 --- a/src/webui/features/tasks/components/task-list-table.tsx +++ b/src/webui/features/tasks/components/task-list-table.tsx @@ -10,7 +10,7 @@ import { } from '@campfirein/byterover-packages/components/table' import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' import {cn} from '@campfirein/byterover-packages/lib/utils' -import {CircleStop, Trash2} from 'lucide-react' +import {CircleStop, LoaderCircle, Trash2} from 'lucide-react' import type {StatusFilter} from '../stores/task-store' import type {StoredTask} from '../types/stored-task' @@ -213,7 +213,7 @@ function TaskRow({ {actionKind === 'delete' ? ( onDelete(task.taskId)} /> ) : ( - onCancel(task.taskId)} /> + onCancel(task.taskId)} /> )} @@ -255,17 +255,17 @@ function DeleteRowAction({onClick}: {onClick: () => void}) { ) } -function CancelRowAction({disabled, onClick}: {disabled: boolean; onClick: () => void}) { +function CancelRowAction({cancelling, onClick}: {cancelling: boolean; onClick: () => void}) { return ( ) } diff --git a/src/webui/features/tasks/components/task-list-view.tsx b/src/webui/features/tasks/components/task-list-view.tsx index b10b5f782..99434d8a8 100644 --- a/src/webui/features/tasks/components/task-list-view.tsx +++ b/src/webui/features/tasks/components/task-list-view.tsx @@ -430,7 +430,13 @@ export function TaskListView() { className="data-[side=right]:w-full data-[side=right]:max-w-3xl p-0 shadow-[inset_1px_0_0_rgba(96,165,250,0.18)]" side="right" > - {selectedTaskId && } + {selectedTaskId && ( + + )}