diff --git a/src/webui/features/tasks/api/get-tasks.ts b/src/webui/features/tasks/api/get-tasks.ts index baa42aa82..730f92cd5 100644 --- a/src/webui/features/tasks/api/get-tasks.ts +++ b/src/webui/features/tasks/api/get-tasks.ts @@ -1,6 +1,4 @@ -import {queryOptions, useQuery} from '@tanstack/react-query' - -import type {QueryConfig} from '../../../lib/react-query' +import {useQuery} from '@tanstack/react-query' import { TaskEvents, @@ -16,19 +14,10 @@ export const getTasks = (data?: TaskListRequest): Promise => { return apiClient.request(TaskEvents.LIST, data) } -export const getTasksQueryOptions = (projectPath?: string) => - queryOptions({ - queryFn: () => getTasks(projectPath ? {projectPath} : undefined), - queryKey: ['tasks', 'list', projectPath ?? ''], - }) - -type UseGetTasksOptions = { - projectPath?: string - queryConfig?: QueryConfig -} +export type UseGetTasksOptions = TaskListRequest -export const useGetTasks = ({projectPath, queryConfig}: UseGetTasksOptions = {}) => +export const useGetTasks = (options: UseGetTasksOptions = {}) => useQuery({ - ...getTasksQueryOptions(projectPath), - ...queryConfig, + queryFn: () => getTasks(options), + queryKey: ['tasks', 'list', options], }) diff --git a/src/webui/features/tasks/components/task-date-filter-panel.tsx b/src/webui/features/tasks/components/task-date-filter-panel.tsx new file mode 100644 index 000000000..3a233c53d --- /dev/null +++ b/src/webui/features/tasks/components/task-date-filter-panel.tsx @@ -0,0 +1,95 @@ +import {Calendar} from '@campfirein/byterover-packages/components/calendar' +import {endOfDay, startOfDay} from 'date-fns' +import {useEffect, useState} from 'react' + +import {formatTimeRangeLabel} from '../utils/time-presets' + +type DateRange = {from: Date | undefined; to?: Date} + +export interface TaskDateFilterPanelProps { + createdAfter?: number + createdBefore?: number + onChange: (range: {createdAfter?: number; createdBefore?: number}) => void +} + +export function TaskDateFilterPanel({createdAfter, createdBefore, onChange}: TaskDateFilterPanelProps) { + const applied = toDateRange(createdAfter, createdBefore) + const [selected, setSelected] = useState(applied) + + useEffect(() => { + setSelected(toDateRange(createdAfter, createdBefore)) + }, [createdAfter, createdBefore]) + + const hasSelection = selected?.from !== undefined && selected?.from !== null + const hasApplied = createdAfter !== undefined || createdBefore !== undefined + const isChanged = + hasSelection && + (toMs(selected.from) !== createdAfter || toMs(selected.to ?? selected.from) !== createdBefore) + + const handleApply = () => { + if (!selected?.from) return + const from = startOfDay(selected.from).getTime() + const to = endOfDay(selected.to ?? selected.from).getTime() + onChange({createdAfter: from, createdBefore: to}) + } + + const handleClear = () => { + setSelected(undefined) + onChange({}) + } + + return ( +
event.stopPropagation()}> + +
+ {hasApplied ? ( + {formatTimeRangeLabel({createdAfter, createdBefore})} + ) : hasSelection ? ( + + {formatTimeRangeLabel({ + createdAfter: toMs(selected.from), + createdBefore: toMs(selected.to ?? selected.from), + })} + + ) : ( + + )} +
+ {hasApplied && ( + + )} + {hasSelection && isChanged && ( + + )} +
+
+
+ ) +} + +function toDateRange(from?: number, to?: number): DateRange | undefined { + if (from === undefined) return undefined + return {from: new Date(from), ...(to === undefined ? {} : {to: new Date(to)})} +} + +function toMs(date?: Date): number | undefined { + return date ? date.getTime() : undefined +} diff --git a/src/webui/features/tasks/components/task-filter-menu.tsx b/src/webui/features/tasks/components/task-filter-menu.tsx new file mode 100644 index 000000000..5e1217ad6 --- /dev/null +++ b/src/webui/features/tasks/components/task-filter-menu.tsx @@ -0,0 +1,211 @@ +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@campfirein/byterover-packages/components/dropdown-menu' +import {SlidersHorizontal} from 'lucide-react' +import {useMemo} from 'react' + +import type {TaskListAvailableModel} from '../../../../shared/transport/events/task-events' +import type {ProviderDTO} from '../../../../shared/transport/types/dto' + +import {DURATION_PRESETS, type DurationPreset, isDurationPreset} from '../utils/duration-presets' +import {TaskDateFilterPanel} from './task-date-filter-panel' + +const TYPE_OPTIONS = [ + {label: 'Curate', value: 'curate'}, + {label: 'Query', value: 'query'}, +] as const + +export interface TaskFilterMenuProps { + availableModels: TaskListAvailableModel[] + availableProviders: string[] + createdAfter?: number + createdBefore?: number + durationPreset: DurationPreset + modelFilter: string[] + onDurationChange: (preset: DurationPreset) => void + onModelChange: (next: string[]) => void + onProviderChange: (next: string[]) => void + onTimeRangeChange: (range: {createdAfter?: number; createdBefore?: number}) => void + onTypeChange: (next: string[]) => void + providerFilter: string[] + providers: ProviderDTO[] + typeFilter: string[] +} + +export function TaskFilterMenu({ + availableModels, + availableProviders, + createdAfter, + createdBefore, + durationPreset, + modelFilter, + onDurationChange, + onModelChange, + onProviderChange, + onTimeRangeChange, + onTypeChange, + providerFilter, + providers, + typeFilter, +}: TaskFilterMenuProps) { + const providerNames = useMemo(() => new Map(providers.map((p) => [p.id, p.name])), [providers]) + const modelOptions = useMemo( + () => filterModelOptions(availableModels, providerFilter), + [availableModels, providerFilter], + ) + const timeActive = createdAfter !== undefined || createdBefore !== undefined + const hasActive = + typeFilter.length > 0 || + providerFilter.length > 0 || + modelFilter.length > 0 || + timeActive || + durationPreset !== 'all' + + return ( + + + + Filter + {hasActive && ( + + )} + + + + + + + Type + {typeFilter.length > 0 && ({typeFilter.length})} + + + + {TYPE_OPTIONS.map((option) => ( + toggleIn(typeFilter, option.value, onTypeChange)} + > + {option.label} + + ))} + + + + + + + Provider + {providerFilter.length > 0 && ({providerFilter.length})} + + + + {availableProviders.length === 0 ? ( +
No providers yet
+ ) : ( + availableProviders.map((provider) => ( + toggleIn(providerFilter, provider, onProviderChange)} + > + {providerNames.get(provider) ?? provider} + + )) + )} +
+
+ + + + + Model + {modelFilter.length > 0 && ({modelFilter.length})} + + + + {modelOptions.length === 0 ? ( +
No models yet
+ ) : ( + modelOptions.map((modelId) => ( + toggleIn(modelFilter, modelId, onModelChange)} + > + {modelId} + + )) + )} +
+
+ + + + + Time + {timeActive && ·} + + + + + + + + + + + Duration + {durationPreset !== 'all' && ·} + + + + isDurationPreset(value) && onDurationChange(value)} + value={durationPreset} + > + {DURATION_PRESETS.map((preset) => ( + + {preset.label} + + ))} + + + +
+
+ ) +} + +function toggleIn(current: string[], value: string, onChange: (next: string[]) => void) { + onChange(current.includes(value) ? current.filter((v) => v !== value) : [...current, value]) +} + +function filterModelOptions(available: TaskListAvailableModel[], selectedProviders: string[]): string[] { + const filtered = + selectedProviders.length === 0 ? available : available.filter((entry) => selectedProviders.includes(entry.providerId)) + const seen = new Set() + const options: string[] = [] + for (const entry of filtered) { + if (seen.has(entry.modelId)) continue + seen.add(entry.modelId) + options.push(entry.modelId) + } + + return options +} diff --git a/src/webui/features/tasks/components/task-filter-tags.tsx b/src/webui/features/tasks/components/task-filter-tags.tsx new file mode 100644 index 000000000..ee17510d2 --- /dev/null +++ b/src/webui/features/tasks/components/task-filter-tags.tsx @@ -0,0 +1,157 @@ +import {Tag} from '@campfirein/byterover-packages/components/tag/tag' +import {X} from 'lucide-react' +import {useMemo} from 'react' + +import type {ProviderDTO} from '../../../../shared/transport/types/dto' +import type {StatusFilter} from '../stores/task-store' +import type {DurationPreset} from '../utils/duration-presets' + +import {durationPresetLabel} from '../utils/duration-presets' +import {formatTimeRangeLabel} from '../utils/time-presets' +import {STATUS_LABEL} from './task-list-filter-bar' + +const TYPE_LABEL: Record = { + curate: 'Curate', + query: 'Query', +} + +export interface TaskFilterTagsProps { + createdAfter?: number + createdBefore?: number + durationPreset: DurationPreset + modelFilter: string[] + onClearAll: () => void + onDurationChange: (preset: DurationPreset) => void + onModelChange: (next: string[]) => void + onProviderChange: (next: string[]) => void + onSearchChange: (query: string) => void + onStatusChange: (filter: StatusFilter) => void + onTimeRangeChange: (range: {createdAfter?: number; createdBefore?: number}) => void + onTypeChange: (next: string[]) => void + providerFilter: string[] + providers: ProviderDTO[] + searchQuery: string + statusFilter: StatusFilter + typeFilter: string[] +} + +export function TaskFilterTags({ + createdAfter, + createdBefore, + durationPreset, + modelFilter, + onClearAll, + onDurationChange, + onModelChange, + onProviderChange, + onSearchChange, + onStatusChange, + onTimeRangeChange, + onTypeChange, + providerFilter, + providers, + searchQuery, + statusFilter, + typeFilter, +}: TaskFilterTagsProps) { + const providerNames = useMemo(() => new Map(providers.map((p) => [p.id, p.name])), [providers]) + + const tags = useMemo(() => { + const result: Array<{key: string; label: string; onRemove: () => void}> = [] + + if (statusFilter !== 'all') { + result.push({ + key: `status:${statusFilter}`, + label: `Status: ${STATUS_LABEL[statusFilter]}`, + onRemove: () => onStatusChange('all'), + }) + } + + for (const value of typeFilter) { + result.push({ + key: `type:${value}`, + label: `Type: ${TYPE_LABEL[value] ?? value}`, + onRemove: () => onTypeChange(typeFilter.filter((v) => v !== value)), + }) + } + + for (const value of providerFilter) { + result.push({ + key: `provider:${value}`, + label: `Provider: ${providerNames.get(value) ?? value}`, + onRemove: () => onProviderChange(providerFilter.filter((v) => v !== value)), + }) + } + + for (const value of modelFilter) { + result.push({ + key: `model:${value}`, + label: `Model: ${value}`, + onRemove: () => onModelChange(modelFilter.filter((v) => v !== value)), + }) + } + + if (createdAfter !== undefined || createdBefore !== undefined) { + result.push({ + key: 'time', + label: `Time: ${formatTimeRangeLabel({createdAfter, createdBefore})}`, + onRemove: () => onTimeRangeChange({}), + }) + } + + if (durationPreset !== 'all') { + result.push({ + key: `duration:${durationPreset}`, + label: `Duration: ${durationPresetLabel(durationPreset)}`, + onRemove: () => onDurationChange('all'), + }) + } + + if (searchQuery.trim()) { + result.push({ + key: 'search', + label: `“${searchQuery.trim()}”`, + onRemove: () => onSearchChange(''), + }) + } + + return result + }, [ + statusFilter, + typeFilter, + providerFilter, + modelFilter, + createdAfter, + createdBefore, + durationPreset, + searchQuery, + providerNames, + onStatusChange, + onTypeChange, + onProviderChange, + onModelChange, + onTimeRangeChange, + onDurationChange, + onSearchChange, + ]) + + if (tags.length === 0) return null + + return ( +
+ {tags.map((tag) => ( + + {tag.label} + + ))} + +
+ ) +} diff --git a/src/webui/features/tasks/components/task-list-empty.tsx b/src/webui/features/tasks/components/task-list-empty.tsx index ec38d2efd..adccfadee 100644 --- a/src/webui/features/tasks/components/task-list-empty.tsx +++ b/src/webui/features/tasks/components/task-list-empty.tsx @@ -28,7 +28,36 @@ export function LoadingState() { return
Loading tasks…
} -export function EmptyState({onNewTask, tourCue}: {onNewTask: () => void; tourCue?: string}) { +export function EmptyState({ + hasActiveFilters, + onClearFilters, + onNewTask, + tourCue, +}: { + hasActiveFilters?: boolean + onClearFilters?: () => void + onNewTask: () => void + tourCue?: string +}) { + if (hasActiveFilters) { + return ( +
+ +
+

No matching tasks

+

+ No tasks match the current filters. Try adjusting or clearing them. +

+
+ {onClearFilters && ( + + )} +
+ ) + } + return (
diff --git a/src/webui/features/tasks/components/task-list-filter-bar.tsx b/src/webui/features/tasks/components/task-list-filter-bar.tsx index ab9af5fec..c5339ee74 100644 --- a/src/webui/features/tasks/components/task-list-filter-bar.tsx +++ b/src/webui/features/tasks/components/task-list-filter-bar.tsx @@ -3,8 +3,13 @@ import {Input} from '@campfirein/byterover-packages/components/input' import {cn} from '@campfirein/byterover-packages/lib/utils' import {Plus, Search} from 'lucide-react' +import type {TaskListAvailableModel, TaskListCounts} from '../../../../shared/transport/events/task-events' +import type {ProviderDTO} from '../../../../shared/transport/types/dto' + import {TourPointer} from '../../onboarding/components/tour-pointer' -import {STATUS_FILTERS, type StatusFilter, type useStatusBreakdown} from '../stores/task-store' +import {STATUS_FILTERS, type StatusFilter} from '../stores/task-store' +import {type DurationPreset} from '../utils/duration-presets' +import {TaskFilterMenu} from './task-filter-menu' export const STATUS_LABEL: Record = { all: 'All', @@ -21,23 +26,53 @@ export const STATUS_DOT_COLOR: Record, string> = { running: 'bg-blue-400', } +export interface FilterBarProps { + availableModels: TaskListAvailableModel[] + availableProviders: string[] + breakdown: TaskListCounts + createdAfter?: number + createdBefore?: number + durationPreset: DurationPreset + modelFilter: string[] + onDurationChange: (preset: DurationPreset) => void + onModelChange: (next: string[]) => void + onNewTask: () => void + onProviderChange: (next: string[]) => void + onSearchChange: (query: string) => void + onStatusChange: (filter: StatusFilter) => void + onTimeRangeChange: (range: {createdAfter?: number; createdBefore?: number}) => void + onTypeChange: (next: string[]) => void + providerFilter: string[] + providers: ProviderDTO[] + searchQuery: string + statusFilter: StatusFilter + tourCue?: string + typeFilter: string[] +} + export function FilterBar({ + availableModels, + availableProviders, breakdown, + createdAfter, + createdBefore, + durationPreset, + modelFilter, + onDurationChange, + onModelChange, onNewTask, + onProviderChange, onSearchChange, onStatusChange, + onTimeRangeChange, + onTypeChange, + providerFilter, + providers, searchQuery, statusFilter, tourCue, -}: { - breakdown: ReturnType - onNewTask: () => void - onSearchChange: (query: string) => void - onStatusChange: (filter: StatusFilter) => void - searchQuery: string - statusFilter: StatusFilter - tourCue?: string -}) { + typeFilter, +}: FilterBarProps) { return (
{STATUS_FILTERS.map((filter) => { @@ -65,10 +100,27 @@ export function FilterBar({ })}
+ +
onSearchChange(event.target.value)} placeholder="Search input or id…" type="text" @@ -77,7 +129,7 @@ export function FilterBar({
- diff --git a/src/webui/features/tasks/components/task-list-pagination.tsx b/src/webui/features/tasks/components/task-list-pagination.tsx new file mode 100644 index 000000000..45374d53b --- /dev/null +++ b/src/webui/features/tasks/components/task-list-pagination.tsx @@ -0,0 +1,113 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@campfirein/byterover-packages/components/pagination' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@campfirein/byterover-packages/components/select' +import {cn} from '@campfirein/byterover-packages/lib/utils' + +const PAGE_SIZE_OPTIONS = [50, 100, 250] as const + +export interface TaskListPaginationProps { + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: number) => void + page: number + pageCount: number + pageSize: number + total: number +} + +export function TaskListPagination({ + onPageChange, + onPageSizeChange, + page, + pageCount, + pageSize, + total, +}: TaskListPaginationProps) { + if (pageCount <= 1 && total <= PAGE_SIZE_OPTIONS[0]) return null + + const pages = pageNumbersToShow(page, pageCount) + const isFirst = page <= 1 + const isLast = page >= pageCount + + return ( +
+ + {total > 0 ? `${total} task${total === 1 ? '' : 's'}` : 'No tasks'} + + {pageCount > 1 && ( + + + + !isFirst && onPageChange(page - 1)} + /> + + {pages.map((entry, idx) => + entry === 'ellipsis' ? ( + + + + ) : ( + + onPageChange(entry)}> + {entry} + + + ), + )} + + !isLast && onPageChange(page + 1)} + /> + + + + )} + +
+ ) +} + +function pageNumbersToShow(current: number, total: number): Array<'ellipsis' | number> { + if (total <= 7) { + return Array.from({length: total}, (_, i) => i + 1) + } + + const result: Array<'ellipsis' | number> = [1] + const left = Math.max(2, current - 1) + const right = Math.min(total - 1, current + 1) + + if (left > 2) result.push('ellipsis') + for (let i = left; i <= right; i++) result.push(i) + if (right < total - 1) result.push('ellipsis') + + result.push(total) + return result +} diff --git a/src/webui/features/tasks/components/task-list-table.tsx b/src/webui/features/tasks/components/task-list-table.tsx index da4d8e4ac..f2c045cec 100644 --- a/src/webui/features/tasks/components/task-list-table.tsx +++ b/src/webui/features/tasks/components/task-list-table.tsx @@ -155,7 +155,9 @@ function TaskRow({ -
{task.content || (empty)}
+
+ {task.content || (empty)} +
{activity && (
@@ -204,7 +206,7 @@ function TaskRow({ function TypeBadge({type}: {type: string}) { return ( - + {displayTaskType(type)} ) diff --git a/src/webui/features/tasks/components/task-list-view.tsx b/src/webui/features/tasks/components/task-list-view.tsx index ab21945b8..a8b28af24 100644 --- a/src/webui/features/tasks/components/task-list-view.tsx +++ b/src/webui/features/tasks/components/task-list-view.tsx @@ -1,6 +1,6 @@ import {Button} from '@campfirein/byterover-packages/components/button' import {Sheet, SheetContent} from '@campfirein/byterover-packages/components/sheet' -import {useEffect, useMemo, useState} from 'react' +import {useCallback, useEffect, useMemo, useState} from 'react' import {useSearchParams} from 'react-router-dom' import {toast} from 'sonner' @@ -9,21 +9,29 @@ import type {ComposerType} from './task-composer-types' 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 {useClearCompleted} from '../api/clear-completed' import {useDeleteBulkTasks} from '../api/delete-bulk-tasks' import {useDeleteTask} from '../api/delete-task' import {useGetTasks} from '../api/get-tasks' +import {useDebouncedValue} from '../hooks/use-debounced-value' +import {useTaskFilterParams} from '../hooks/use-task-filter-params' import {useTickingNow} from '../hooks/use-ticking-now' import {useComposerRetryStore} from '../stores/composer-retry-store' -import {statusMatchesFilter, taskMatchesQuery, useStatusBreakdown, useTaskStore} from '../stores/task-store' +import {useTaskStore} from '../stores/task-store' +import {durationPresetToRange} from '../utils/duration-presets' +import {statusFilterToServer} from '../utils/status-filter-to-server' import {isTerminalStatus} from '../utils/task-status' import {TaskComposerSheet} from './task-composer' import {TaskDetailView} from './task-detail-view' +import {TaskFilterTags} from './task-filter-tags' import {BulkActionsBar} from './task-list-bulk-actions' import {EmptyState, LoadingState, PlaceholderCard} from './task-list-empty' import {FilterBar} from './task-list-filter-bar' +import {TaskListPagination} from './task-list-pagination' import {TaskTable} from './task-list-table' +// eslint-disable-next-line complexity export function TaskListView() { const [searchParams, setSearchParams] = useSearchParams() const selectedTaskId = searchParams.get('task') ?? undefined @@ -35,27 +43,88 @@ export function TaskListView() { }) } - const closeTask = () => { + const closeTask = useCallback(() => { setSearchParams((prev) => { const next = new URLSearchParams(prev) next.delete('task') return next }) - } + }, [setSearchParams]) const projectPath = useTransportStore((s) => s.selectedProject) - const tasks = useTaskStore((s) => s.tasks) - const statusFilter = useTaskStore((s) => s.statusFilter) - const setStatusFilter = useTaskStore((s) => s.setStatusFilter) - const searchQuery = useTaskStore((s) => s.searchQuery) - const setSearchQuery = useTaskStore((s) => s.setSearchQuery) const clearCompleted = useTaskStore((s) => s.clearCompleted) const removeTask = useTaskStore((s) => s.removeTask) - const breakdown = useStatusBreakdown() - const {isLoading} = useGetTasks({projectPath: projectPath || undefined}) + const { + clearAllFilters, + filters, + setDurationPreset, + setModelFilter, + setPage, + setPageSize, + setProviderFilter, + setSearchQuery, + setStatusFilter, + setTimeRange, + setTypeFilter, + } = useTaskFilterParams() + const {data: providersResponse} = useGetProviders() + const providers = providersResponse?.providers ?? [] + const { + createdAfter, + createdBefore, + durationPreset, + modelFilter, + page, + pageSize, + providerFilter, + searchQuery, + statusFilter, + typeFilter, + } = filters + + const durationRange = useMemo(() => durationPresetToRange(durationPreset), [durationPreset]) + const debouncedSearch = useDebouncedValue(searchQuery, 300) + + const nonStatusFilters = useMemo( + () => ({ + projectPath: projectPath || undefined, + ...(typeFilter.length > 0 ? {type: typeFilter} : {}), + ...(providerFilter.length > 0 ? {provider: providerFilter} : {}), + ...(modelFilter.length > 0 ? {model: modelFilter} : {}), + ...(createdAfter === undefined ? {} : {createdAfter}), + ...(createdBefore === undefined ? {} : {createdBefore}), + ...durationRange, + ...(debouncedSearch.trim() ? {searchText: debouncedSearch.trim()} : {}), + }), + [projectPath, typeFilter, providerFilter, modelFilter, createdAfter, createdBefore, durationRange, debouncedSearch], + ) + + const serverStatus = useMemo(() => statusFilterToServer(statusFilter), [statusFilter]) + const {data, isLoading} = useGetTasks({ + page, + pageSize, + ...nonStatusFilters, + ...(serverStatus ? {status: serverStatus} : {}), + }) + + const {data: countsData} = useGetTasks({page: 1, pageSize: 1, ...nonStatusFilters}) + + const tasks = data?.tasks ?? [] + const breakdown = countsData?.counts ?? {all: 0, cancelled: 0, completed: 0, failed: 0, running: 0} + const availableProviders = data?.availableProviders ?? [] + const availableModels = data?.availableModels ?? [] const now = useTickingNow(breakdown.running > 0) + const hasActiveFilters = + statusFilter !== 'all' || + typeFilter.length > 0 || + providerFilter.length > 0 || + modelFilter.length > 0 || + createdAfter !== undefined || + createdBefore !== undefined || + durationPreset !== 'all' || + searchQuery.trim().length > 0 const deleteMutation = useDeleteTask() const deleteBulkMutation = useDeleteBulkTasks() @@ -93,8 +162,6 @@ export function TaskListView() { const closeComposer = () => setComposer({open: false}) - // Pick up retry seeds from the task-detail "Try again" CTA. Both normal and - // tour mode use this composer now, so the seed flow is shared. const retrySeed = useComposerRetryStore((s) => s.seed) const consumeRetry = useComposerRetryStore((s) => s.consume) @@ -110,23 +177,19 @@ export function TaskListView() { if (openDetail) openTask(taskId) } - // O(1) taskId → task lookup. Replaces repeated tasks.find() in bulk action paths. const taskMap = useMemo(() => new Map(tasks.map((task) => [task.taskId, task])), [tasks]) const filtered = useMemo( () => - tasks - .filter((task) => statusMatchesFilter(task.status, statusFilter)) - .filter((task) => taskMatchesQuery(task, searchQuery)) - .sort((a, b) => { - const aActive = isTerminalStatus(a.status) ? 1 : 0 - const bActive = isTerminalStatus(b.status) ? 1 : 0 - if (aActive !== bActive) return aActive - bActive - const aRef = a.completedAt ?? a.startedAt ?? a.createdAt - const bRef = b.completedAt ?? b.startedAt ?? b.createdAt - return bRef - aRef - }), - [tasks, statusFilter, searchQuery], + [...tasks].sort((a, b) => { + const aActive = isTerminalStatus(a.status) ? 1 : 0 + const bActive = isTerminalStatus(b.status) ? 1 : 0 + if (aActive !== bActive) return aActive - bActive + const aRef = a.completedAt ?? a.startedAt ?? a.createdAt + const bRef = b.completedAt ?? b.startedAt ?? b.createdAt + return bRef - aRef + }), + [tasks], ) const allFilteredSelected = filtered.length > 0 && filtered.every((task) => selectedIds.has(task.taskId)) @@ -212,28 +275,80 @@ export function TaskListView() { /> ) : ( { + setDurationPreset(next) + clearSelection() + }} + onModelChange={(next) => { + setModelFilter(next) + clearSelection() + }} onNewTask={openComposer} + onProviderChange={(next) => { + setProviderFilter(next) + clearSelection() + }} onSearchChange={setSearchQuery} onStatusChange={(filter) => { setStatusFilter(filter) clearSelection() }} + onTimeRangeChange={(next) => { + setTimeRange(next) + clearSelection() + }} + onTypeChange={(next) => { + setTypeFilter(next) + clearSelection() + }} + providerFilter={providerFilter} + providers={providers} searchQuery={searchQuery} statusFilter={statusFilter} - // Coachmark moves between the empty-state CTA and the header CTA so - // we never highlight both simultaneously. tourCue={tourCueLabel && tasks.length > 0 ? tourCueLabel : undefined} + typeFilter={typeFilter} /> )} + + {isLoading ? ( ) : tasks.length === 0 ? ( - + ) : ( )} + {data && ( + + )} + {finishedCount > 0 && (