Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/webui/features/tasks/api/cancel-task.ts
Original file line number Diff line number Diff line change
@@ -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<TaskCancelResponse> => {
const {apiClient} = useTransportStore.getState()
if (!apiClient) throw new Error('Not connected')

const response = await apiClient.request<TaskCancelResponse, TaskCancelRequest>(TaskEvents.CANCEL, payload)
if (!response.success) throw new Error(response.error ?? 'Cancel failed')
return response
}

type UseCancelTaskOptions = {
mutationConfig?: MutationConfig<typeof cancelTask>
}

export const useCancelTask = ({mutationConfig}: UseCancelTaskOptions = {}) =>
useMutation({
...mutationConfig,
mutationFn: cancelTask,
})
28 changes: 24 additions & 4 deletions src/webui/features/tasks/components/task-detail-header.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -18,8 +19,16 @@ const STATUS_VERB: Record<StoredTask['status'], string> = {
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]
Expand All @@ -42,11 +51,22 @@ export function DetailHeader({now, task}: {now: number; task: StoredTask}) {
{verb} {formatRelative(referenceTime, now)} ago
</span>
<Separator />
<span
className={cn('mono tabular-nums', isActiveStatus(task.status) ? 'text-blue-400' : 'text-muted-foreground')}
>
<span className={cn('mono tabular-nums', isActive ? 'text-blue-400' : 'text-muted-foreground')}>
{elapsedLabel} {formatDuration(elapsed)}
</span>
{isActive && (
<Button
aria-label="Cancel task"
className="ml-1 h-6 gap-1 border-red-500/40 px-2 text-red-400 hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-300"
disabled={cancelling}
onClick={() => onCancel(task.taskId)}
size="xs"
variant="outline"
>
{cancelling ? <LoaderCircle className="size-3 animate-spin" /> : <CircleStop className="size-3" />}
{cancelling ? 'Cancelling…' : 'Cancel'}
</Button>
)}
Comment thread
ncnthien marked this conversation as resolved.
</div>
</header>
)
Expand Down
6 changes: 4 additions & 2 deletions src/webui/features/tasks/components/task-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -80,7 +82,7 @@ export function TaskDetailView({taskId}: TaskDetailViewProps) {

return (
<div className="flex h-full min-h-0 flex-col">
<DetailHeader now={now} task={task} />
<DetailHeader cancelling={cancelling} now={now} onCancel={onCancel} task={task} />
<div className="border-border/50 border-t" />
<div className="flex min-h-0 flex-1 flex-col gap-7 overflow-y-auto px-6 py-5" onScroll={onScroll} ref={scrollRef}>
<TourTaskBanner task={task} />
Expand Down
37 changes: 34 additions & 3 deletions src/webui/features/tasks/components/task-list-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -46,8 +47,10 @@ function durationOf(task: StoredTask, now: number): string {

interface TaskTableProps {
allSelected: boolean
cancellingIds: Set<string>
filtered: StoredTask[]
now: number
onCancel: (taskId: string) => void
onClearSearch: () => void
onDelete: (taskId: string) => void
onRowClick: (taskId: string) => void
Expand All @@ -61,8 +64,10 @@ interface TaskTableProps {

export function TaskTable({
allSelected,
cancellingIds,
filtered,
now,
onCancel,
onClearSearch,
onDelete,
onRowClick,
Expand Down Expand Up @@ -100,9 +105,11 @@ export function TaskTable({
) : (
filtered.map((task) => (
<TaskRow
cancelling={cancellingIds.has(task.taskId)}
isSelected={selectedIds.has(task.taskId)}
key={task.taskId}
now={now}
onCancel={onCancel}
onDelete={onDelete}
onRowClick={onRowClick}
onToggleSelect={onToggleSelect}
Expand All @@ -117,16 +124,20 @@ export function TaskTable({
}

function TaskRow({
cancelling,
isSelected,
now,
onCancel,
onDelete,
onRowClick,
onToggleSelect,
providerNames,
task,
}: {
cancelling: boolean
isSelected: boolean
now: number
onCancel: (taskId: string) => void
onDelete: (taskId: string) => void
onRowClick: (taskId: string) => void
onToggleSelect: (taskId: string) => void
Expand All @@ -137,6 +148,7 @@ function TaskRow({
const isRunning = !terminal
const interrupted = isInterrupted(task)
const activity = getCurrentActivity(task)
const actionKind = rowActionKind(task.status)

const row = (
<TableRow
Expand Down Expand Up @@ -198,7 +210,11 @@ function TaskRow({
{durationOf(task, now)}
</TableCell>
<TableCell className="text-center" onClick={(event) => event.stopPropagation()}>
{terminal && <RowAction onClick={() => onDelete(task.taskId)} />}
{actionKind === 'delete' ? (
<DeleteRowAction onClick={() => onDelete(task.taskId)} />
) : (
<CancelRowAction cancelling={cancelling} onClick={() => onCancel(task.taskId)} />
)}
</TableCell>
Comment thread
ncnthien marked this conversation as resolved.
</TableRow>
)
Expand Down Expand Up @@ -231,14 +247,29 @@ function ProviderChip({model, provider, providerName}: {model?: string; provider
)
}

function RowAction({onClick}: {onClick: () => void}) {
function DeleteRowAction({onClick}: {onClick: () => void}) {
return (
<Button aria-label="Delete" onClick={onClick} size="icon-xs" title="Delete" variant="ghost">
<Trash2 className="size-3.5" />
</Button>
)
}

function CancelRowAction({cancelling, onClick}: {cancelling: boolean; onClick: () => void}) {
return (
<Button
aria-label="Cancel task"
disabled={cancelling}
onClick={onClick}
size="icon-xs"
title={cancelling ? 'Cancelling…' : 'Cancel task'}
variant="ghost"
>
{cancelling ? <LoaderCircle className="size-3.5 animate-spin" /> : <CircleStop className="size-3.5" />}
</Button>
)
}

function Checkbox({checked, onChange}: {checked: boolean; onChange: () => void}) {
return (
<input
Expand Down
40 changes: 39 additions & 1 deletion src/webui/features/tasks/components/task-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {useTransportStore} from '../../../stores/transport-store'
import {CURATE_EXAMPLE, QUERY_EXAMPLE, TOUR_STEP_LABEL} from '../../onboarding/lib/tour-examples'
import {useOnboardingStore} from '../../onboarding/stores/onboarding-store'
import {useGetProviders} from '../../provider/api/get-providers'
import {useCancelTask} from '../api/cancel-task'
import {useClearCompleted} from '../api/clear-completed'
import {useDeleteBulkTasks} from '../api/delete-bulk-tasks'
import {useDeleteTask} from '../api/delete-task'
Expand Down Expand Up @@ -133,8 +134,10 @@ export function TaskListView() {
const deleteMutation = useDeleteTask()
const deleteBulkMutation = useDeleteBulkTasks()
const clearCompletedMutation = useClearCompleted()
const cancelMutation = useCancelTask()

const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [cancellingIds, setCancellingIds] = useState<Set<string>>(new Set())
const [composer, setComposer] = useState<{
initialContent?: string
initialType?: ComposerType
Expand Down Expand Up @@ -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,
},
)
}
Comment thread
ncnthien marked this conversation as resolved.

const deleteSelected = () => {
const eligibleIds = [...selectedIds].filter((id) => {
const task = taskMap.get(id)
Expand Down Expand Up @@ -357,8 +387,10 @@ export function TaskListView() {
) : (
<TaskTable
allSelected={allFilteredSelected}
cancellingIds={cancellingIds}
filtered={filtered}
now={now}
onCancel={handleCancel}
onClearSearch={() => setSearchQuery('')}
onDelete={handleDelete}
onRowClick={openTask}
Expand Down Expand Up @@ -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 && <TaskDetailView taskId={selectedTaskId} />}
{selectedTaskId && (
<TaskDetailView
cancelling={cancellingIds.has(selectedTaskId)}
onCancel={handleCancel}
taskId={selectedTaskId}
/>
)}
</SheetContent>
</Sheet>

Expand Down
9 changes: 9 additions & 0 deletions src/webui/features/tasks/utils/row-action-kind.ts
Original file line number Diff line number Diff line change
@@ -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'
}
Comment thread
ncnthien marked this conversation as resolved.
69 changes: 69 additions & 0 deletions test/unit/webui/features/tasks/api/cancel-task.test.ts
Original file line number Diff line number Diff line change
@@ -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')
}
})
})
Loading
Loading