Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CheckIcon, ChevronDownIcon, RefreshCw, X } from 'lucide-react';
import { CheckIcon, RefreshCw, X } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import { motion } from 'motion/react';
import { useState } from 'react';
Expand All @@ -9,6 +9,8 @@ import {
type StatusFilter,
} from '@renderer/features/projects/components/pr-view/usePrViewState';
import { getRepositoryStore } from '@renderer/features/projects/stores/project-selectors';
import { FilterMenuButton } from '@renderer/lib/components/filter-menu-button';
import { SortSelect } from '@renderer/lib/components/sort-select';
import { useParams } from '@renderer/lib/layout/navigation-provider';
import { Button } from '@renderer/lib/ui/button';
import {
Expand All @@ -18,15 +20,7 @@ import {
ContextMenuTrigger,
} from '@renderer/lib/ui/context-menu';
import { Input } from '@renderer/lib/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/lib/ui/popover';
import { SearchInput } from '@renderer/lib/ui/search-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/lib/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group';
import type { PrSortField } from '@shared/core/pull-requests/pull-requests';
import { PrSyncStatusCard } from './pr-sync-status-card';
Expand All @@ -38,36 +32,6 @@ const SORT_OPTIONS: { value: PrSortField; label: string }[] = [
{ value: 'recently-updated', label: 'Recently Updated' },
];

function FilterButton({
label,
active,
disabled,
children,
}: {
label: string;
active: boolean;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<Popover>
<PopoverTrigger
disabled={disabled}
className={
'flex items-center gap-1 text-sm hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40' +
(active ? 'font-medium text-foreground' : 'text-foreground-muted')
}
>
{label}
<ChevronDownIcon className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="start" className="w-56 gap-0 p-2">
{children}
</PopoverContent>
</Popover>
);
}

function UserFilterPopover({
label,
items,
Expand All @@ -83,7 +47,7 @@ function UserFilterPopover({
const filtered = items.filter((i) => i.label.toLowerCase().includes(search.toLowerCase()));

return (
<FilterButton label={label} active={selected !== null} disabled={items.length === 0}>
<FilterMenuButton label={label} active={selected !== null} disabled={items.length === 0}>
<Input
className="mb-1 h-7 text-xs"
placeholder={`Search ${label.toLowerCase()}…`}
Expand Down Expand Up @@ -118,7 +82,7 @@ function UserFilterPopover({
<li className="text-muted-foreground px-2 py-3 text-center text-xs">No results</li>
)}
</ul>
</FilterButton>
</FilterMenuButton>
);
}

Expand All @@ -138,7 +102,7 @@ function LabelFilterPopover({
onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);

return (
<FilterButton label="Label" active={selected.length > 0} disabled={items.length === 0}>
<FilterMenuButton label="Label" active={selected.length > 0} disabled={items.length === 0}>
<Input
className="mb-1 h-7 text-xs"
placeholder="Search labels…"
Expand Down Expand Up @@ -172,7 +136,7 @@ function LabelFilterPopover({
<li className="text-muted-foreground px-2 py-3 text-center text-xs">No results</li>
)}
</ul>
</FilterButton>
</FilterMenuButton>
);
}

Expand Down Expand Up @@ -301,24 +265,11 @@ export const PullRequestView = observer(function PullRequestView() {
{/* ── Sort + filter row ── */}
<div className="flex flex-col flex-wrap gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="text-sm text-foreground-passive">Sort</span>
<Select value={sortFilter} onValueChange={handleSortChange}>
<SelectTrigger
size="sm"
className="w-auto gap-1 border-none p-0 text-foreground-muted hover:text-foreground"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<SortSelect
value={sortFilter}
options={SORT_OPTIONS}
onValueChange={handleSortChange}
/>

<div className="flex items-center gap-3">
<span className="text-sm text-foreground-passive">Filter by</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { taskAgentStatus } from '@renderer/features/tasks/stores/task-selectors';
import type { TaskStore } from '@renderer/features/tasks/stores/task-store';
import type { AgentStatus } from '@shared/core/agents/agentEvents';
import { selectCurrentPr } from '@shared/core/pull-requests/pull-requests';
import type { Task } from '@shared/core/tasks/tasks';

export type FilterableTask = TaskStore & { data: Task };

export type TaskSortField = 'newest' | 'oldest' | 'recently-updated' | 'name';
export type TaskPrFilterValue = 'open' | 'merged' | 'closed' | 'none';
export type TaskChangesFilterValue = 'has-changes' | 'no-changes';

export const TASK_SORT_OPTIONS: readonly { value: TaskSortField; label: string }[] = [
{ value: 'newest', label: 'Newest' },
{ value: 'oldest', label: 'Oldest' },
{ value: 'recently-updated', label: 'Recently updated' },
{ value: 'name', label: 'Name (A–Z)' },
];

export const AGENT_FILTER_OPTIONS: readonly { value: AgentStatus; label: string }[] = [
{ value: 'working', label: 'Working' },
{ value: 'awaiting-input', label: 'Awaiting input' },
{ value: 'completed', label: 'Completed' },
{ value: 'error', label: 'Error' },
{ value: 'idle', label: 'Idle' },
];

export const PR_FILTER_OPTIONS: readonly { value: TaskPrFilterValue; label: string }[] = [
{ value: 'open', label: 'Open PR' },
{ value: 'merged', label: 'Merged' },
{ value: 'closed', label: 'Closed' },
{ value: 'none', label: 'No PR' },
];

export const CHANGES_FILTER_OPTIONS: readonly { value: TaskChangesFilterValue; label: string }[] = [
{ value: 'has-changes', label: 'Has changes' },
{ value: 'no-changes', label: 'No changes' },
];

export type TaskFilters = {
agent: ReadonlySet<AgentStatus>;
pr: ReadonlySet<TaskPrFilterValue>;
changes: ReadonlySet<TaskChangesFilterValue>;
};

function parseTime(value: string | undefined): number {
if (!value) return 0;
const time = Date.parse(value);
return Number.isNaN(time) ? 0 : time;
}

function lastUpdatedTime(task: FilterableTask): number {
return parseTime(task.data.lastInteractedAt ?? task.data.updatedAt ?? task.data.createdAt);
}

export function taskPrFilterValue(task: FilterableTask): TaskPrFilterValue {
const pr = selectCurrentPr(task.data.prs ?? []);
return pr ? pr.status : 'none';
}

export function taskChangesFilterValue(task: FilterableTask): TaskChangesFilterValue {
const git = task.data.workspaceGit;
return git && git.linesAdded + git.linesDeleted > 0 ? 'has-changes' : 'no-changes';
}

/** A task passes when it matches every active dimension (AND), with OR within a dimension. */
export function taskMatchesFilters(task: FilterableTask, filters: TaskFilters): boolean {
if (filters.agent.size > 0 && !filters.agent.has(taskAgentStatus(task) ?? 'idle')) return false;
Comment thread
janburzinski marked this conversation as resolved.
Outdated
if (filters.pr.size > 0 && !filters.pr.has(taskPrFilterValue(task))) return false;
if (filters.changes.size > 0 && !filters.changes.has(taskChangesFilterValue(task))) return false;
return true;
}

/** Pinned tasks always sort first; remaining order follows the chosen sort field. */
export function sortTasks<T extends FilterableTask>(tasks: T[], sortBy: TaskSortField): T[] {
return [...tasks].sort((a, b) => {
if (a.data.isPinned !== b.data.isPinned) return a.data.isPinned ? -1 : 1;
switch (sortBy) {
case 'newest':
return parseTime(b.data.createdAt) - parseTime(a.data.createdAt);
case 'oldest':
return parseTime(a.data.createdAt) - parseTime(b.data.createdAt);
case 'recently-updated':
return lastUpdatedTime(b) - lastUpdatedTime(a);
case 'name':
return a.data.name.localeCompare(b.data.name);
}
});
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useHotkey } from '@tanstack/react-hotkeys';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Archive, RotateCcw, Trash2, X } from 'lucide-react';
import { Archive, CheckIcon, RotateCcw, Trash2, X } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import { AnimatePresence, motion } from 'motion/react';
import { useRef } from 'react';
import { asMounted, getProjectStore } from '@renderer/features/projects/stores/project-selectors';
import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key';
import { getTaskManagerStore } from '@renderer/features/tasks/stores/task-selectors';
import { FilterMenuButton } from '@renderer/lib/components/filter-menu-button';
import { ListPopoverCard } from '@renderer/lib/components/list-popover-card';
import { SortSelect } from '@renderer/lib/components/sort-select';
import {
getEffectiveHotkey,
getHotkeyRegistration,
Expand All @@ -20,9 +23,60 @@ import { SearchInput } from '@renderer/lib/ui/search-input';
import { BoundShortcut } from '@renderer/lib/ui/shortcut';
import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group';
import { cn } from '@renderer/utils/utils';
import {
AGENT_FILTER_OPTIONS,
CHANGES_FILTER_OPTIONS,
PR_FILTER_OPTIONS,
TASK_SORT_OPTIONS,
sortTasks,
taskMatchesFilters,
type TaskFilters,
} from './task-filters';
import { TaskListEmptyState } from './task-list-empty-state';
import { TaskRow, type ReadyTask } from './task-row';

function FilterMenu<T extends string>({
label,
options,
selected,
onToggle,
}: {
label: string;
options: readonly { value: T; label: string }[];
selected: ReadonlySet<T>;
onToggle: (value: T) => void;
}) {
const active = selected.size > 0;
return (
<FilterMenuButton
label={label}
active={active}
badge={
active ? (
<span className="text-xs text-foreground-muted">({selected.size})</span>
) : undefined
}
contentClassName="w-48 p-1"
>
<ul className="max-h-60 overflow-y-auto">
{options.map((option) => (
<li key={option.value}>
<button
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-background-1"
onClick={() => onToggle(option.value)}
>
<span className="flex-1 truncate text-left">{option.label}</span>
{selected.has(option.value) && (
<CheckIcon className="size-3.5 shrink-0 text-foreground" />
)}
</button>
</li>
))}
</ul>
</FilterMenuButton>
);
}

function TaskVirtualList({
tasks,
selectedIds,
Expand Down Expand Up @@ -207,9 +261,19 @@ export const TaskList = observer(function TaskList() {

const displayTasks = taskView.tab === 'active' ? activeTasks : archivedTasks;
const q = taskView.searchQuery.trim().toLowerCase();
const filteredTasks = q
? displayTasks.filter((t) => t.data.name.toLowerCase().includes(q))
: displayTasks;
const filters: TaskFilters = {
agent: taskView.agentFilter,
pr: taskView.prFilter,
changes: taskView.changesFilter,
};
const filteredTasks = sortTasks(
displayTasks.filter(
(t) => (!q || t.data.name.toLowerCase().includes(q)) && taskMatchesFilters(t, filters)
),
taskView.sortBy
);
const showOnboardingEmptyState =
filteredTasks.length === 0 && taskView.tab === 'active' && !q && !taskView.hasActiveFilters;
Comment thread
janburzinski marked this conversation as resolved.
Outdated

return (
<div className="relative flex h-full min-h-0 w-full flex-col">
Expand Down Expand Up @@ -237,9 +301,54 @@ export const TaskList = observer(function TaskList() {
</Button>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<SortSelect
value={taskView.sortBy}
options={TASK_SORT_OPTIONS}
onValueChange={(value) => taskView.setSortBy(value)}
/>
<div className="flex flex-wrap items-center">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-foreground-passive">Filter by</span>
<FilterMenu
label="Agent"
options={AGENT_FILTER_OPTIONS}
selected={taskView.agentFilter}
onToggle={(value) => taskView.toggleAgentFilter(value)}
/>
<FilterMenu
label="PR"
options={PR_FILTER_OPTIONS}
selected={taskView.prFilter}
onToggle={(value) => taskView.togglePrFilter(value)}
/>
<FilterMenu
label="Changes"
options={CHANGES_FILTER_OPTIONS}
selected={taskView.changesFilter}
onToggle={(value) => taskView.toggleChangesFilter(value)}
/>
</div>
<AnimatePresence initial={false}>
{taskView.hasActiveFilters && (
<motion.button
key="clear-filters"
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="shrink-0 overflow-hidden pl-3 text-xs whitespace-nowrap text-foreground-muted hover:text-foreground"
onClick={() => taskView.clearFilters()}
>
Clear
</motion.button>
)}
</AnimatePresence>
</div>
</div>
</div>

{filteredTasks.length === 0 && taskView.tab === 'active' ? (
{showOnboardingEmptyState ? (
<TaskListEmptyState projectId={projectId} />
) : (
<TaskVirtualList
Expand Down
Loading
Loading