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..360b74a69 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, LoaderCircle} 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..9f21f07b9 100644 --- a/src/webui/features/tasks/components/task-detail-view.tsx +++ b/src/webui/features/tasks/components/task-detail-view.tsx @@ -15,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 } @@ -26,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 @@ -80,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 e1ea597d7..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 {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' @@ -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} @@ -398,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 && ( + + )} 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') + }) +})