Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
21 changes: 5 additions & 16 deletions src/webui/features/tasks/api/get-tasks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,19 +14,10 @@ export const getTasks = (data?: TaskListRequest): Promise<TaskListResponse> => {
return apiClient.request<TaskListResponse, TaskListRequest>(TaskEvents.LIST, data)
}

export const getTasksQueryOptions = (projectPath?: string) =>
queryOptions({
queryFn: () => getTasks(projectPath ? {projectPath} : undefined),
queryKey: ['tasks', 'list', projectPath ?? ''],
})

type UseGetTasksOptions = {
projectPath?: string
queryConfig?: QueryConfig<typeof getTasksQueryOptions>
}
export type UseGetTasksOptions = TaskListRequest

export const useGetTasks = ({projectPath, queryConfig}: UseGetTasksOptions = {}) =>
export const useGetTasks = (options: UseGetTasksOptions = {}) =>
Comment thread
ncnthien marked this conversation as resolved.
useQuery({
...getTasksQueryOptions(projectPath),
...queryConfig,
queryFn: () => getTasks(options),
queryKey: ['tasks', 'list', options],
Comment thread
ncnthien marked this conversation as resolved.
})
95 changes: 95 additions & 0 deletions src/webui/features/tasks/components/task-date-filter-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<DateRange | undefined>(applied)

useEffect(() => {
setSelected(toDateRange(createdAfter, createdBefore))
}, [createdAfter, createdBefore])

const hasSelection = selected?.from !== undefined && selected?.from !== null
Comment thread
ncnthien marked this conversation as resolved.
Comment thread
ncnthien marked this conversation as resolved.
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 (
<div onKeyDown={(event) => event.stopPropagation()}>
<Calendar
className="min-w-md bg-transparent p-2"
mode="range"
numberOfMonths={2}
onSelect={setSelected}
selected={selected}
/>
<div className="border-border flex items-center justify-between border-t px-3 py-2">
{hasApplied ? (
<span className="text-foreground text-xs">{formatTimeRangeLabel({createdAfter, createdBefore})}</span>
) : hasSelection ? (
<span className="text-muted-foreground text-xs">
{formatTimeRangeLabel({
createdAfter: toMs(selected.from),
createdBefore: toMs(selected.to ?? selected.from),
})}
</span>
) : (
<span />
)}
<div className="flex items-center gap-2">
{hasApplied && (
<button
className="text-muted-foreground hover:text-foreground text-xs transition"
onClick={handleClear}
type="button"
>
Clear
</button>
)}
{hasSelection && isChanged && (
<button
className="bg-primary text-foreground rounded px-2.5 py-1 text-xs font-medium"
onClick={handleApply}
type="button"
>
Apply
</button>
)}
</div>
</div>
</div>
)
}

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
}
211 changes: 211 additions & 0 deletions src/webui/features/tasks/components/task-filter-menu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger className="text-muted-foreground hover:text-foreground hover:bg-muted/60 border-border bg-background relative inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-3 text-sm transition-colors">
<SlidersHorizontal className="pointer-events-none size-3.5" />
<span className="pointer-events-none">Filter</span>
{hasActive && (
<span className="bg-primary pointer-events-none absolute -top-1 -right-1 size-2 rounded-full" />
)}
</DropdownMenuTrigger>

<DropdownMenuContent align="start" className="w-48">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<span>
Type
{typeFilter.length > 0 && <span className="ml-1">({typeFilter.length})</span>}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48" sideOffset={8}>
{TYPE_OPTIONS.map((option) => (
<DropdownMenuCheckboxItem
checked={typeFilter.includes(option.value)}
className="cursor-pointer"
key={option.value}
onCheckedChange={() => toggleIn(typeFilter, option.value, onTypeChange)}
>
{option.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>

<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<span>
Provider
{providerFilter.length > 0 && <span className="ml-1">({providerFilter.length})</span>}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56" sideOffset={8}>
{availableProviders.length === 0 ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs">No providers yet</div>
) : (
availableProviders.map((provider) => (
<DropdownMenuCheckboxItem
checked={providerFilter.includes(provider)}
className="cursor-pointer"
key={provider}
onCheckedChange={() => toggleIn(providerFilter, provider, onProviderChange)}
>
{providerNames.get(provider) ?? provider}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>

<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<span>
Model
{modelFilter.length > 0 && <span className="ml-1">({modelFilter.length})</span>}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56" sideOffset={8}>
{modelOptions.length === 0 ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs">No models yet</div>
) : (
modelOptions.map((modelId) => (
<DropdownMenuCheckboxItem
checked={modelFilter.includes(modelId)}
className="cursor-pointer"
key={modelId}
onCheckedChange={() => toggleIn(modelFilter, modelId, onModelChange)}
>
{modelId}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>

<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<span>
Time
{timeActive && <span className="text-primary ml-1">·</span>}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-fit" sideOffset={8}>
<TaskDateFilterPanel
createdAfter={createdAfter}
createdBefore={createdBefore}
onChange={onTimeRangeChange}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>

<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<span>
Duration
{durationPreset !== 'all' && <span className="text-primary ml-1">·</span>}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48" sideOffset={8}>
<DropdownMenuRadioGroup
onValueChange={(value) => isDurationPreset(value) && onDurationChange(value)}
value={durationPreset}
>
{DURATION_PRESETS.map((preset) => (
<DropdownMenuRadioItem className="cursor-pointer" key={preset.value} value={preset.value}>
{preset.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}

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<string>()
const options: string[] = []
for (const entry of filtered) {
if (seen.has(entry.modelId)) continue
seen.add(entry.modelId)
options.push(entry.modelId)
}

return options
}
Loading
Loading