From 6237f1a0fe64236c1164185a26693c8d0bb42d6b Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 16 Jan 2026 20:56:23 +0100 Subject: [PATCH 1/2] feat: add filtering capabilities to GitHub issues view - Implemented a comprehensive filtering system for GitHub issues, allowing users to filter by state, labels, assignees, and validation status. - Introduced a new IssuesFilterControls component for managing filter options. - Updated the GitHubIssuesView to utilize the new filtering logic, enhancing the user experience by providing clearer visibility into matching issues. - Added hooks for filtering logic and state management, ensuring efficient updates and rendering of filtered issues. These changes aim to improve the usability of the issues view by enabling users to easily navigate and manage their issues based on specific criteria. --- .../components/views/github-issues-view.tsx | 100 +++++++- .../github-issues-view/components/index.ts | 1 + .../components/issues-filter-controls.tsx | 180 +++++++++++++ .../components/issues-list-header.tsx | 88 ++++++- .../views/github-issues-view/hooks/index.ts | 1 + .../hooks/use-issues-filter.ts | 239 ++++++++++++++++++ .../views/github-issues-view/types.ts | 105 ++++++++ apps/ui/src/hooks/index.ts | 1 + 8 files changed, 689 insertions(+), 26 deletions(-) create mode 100644 apps/ui/src/components/views/github-issues-view/components/issues-filter-controls.tsx create mode 100644 apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index e1e09cad..a34f1225 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -1,20 +1,26 @@ // @ts-nocheck import { useState, useCallback, useMemo } from 'react'; import { createLogger } from '@automaker/utils/logger'; -import { CircleDot, RefreshCw } from 'lucide-react'; +import { CircleDot, RefreshCw, SearchX } from 'lucide-react'; import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { LoadingState } from '@/components/ui/loading-state'; import { ErrorState } from '@/components/ui/error-state'; import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; -import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'; +import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks'; import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; import { ValidationDialog } from './github-issues-view/dialogs'; import { formatDate, getFeaturePriority } from './github-issues-view/utils'; import { useModelOverride } from '@/components/shared'; -import type { ValidateIssueOptions } from './github-issues-view/types'; +import type { + ValidateIssueOptions, + IssuesFilterState, + IssuesStateFilter, +} from './github-issues-view/types'; +import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types'; const logger = createLogger('GitHubIssuesView'); @@ -26,6 +32,9 @@ export function GitHubIssuesView() { const [pendingRevalidateOptions, setPendingRevalidateOptions] = useState(null); + // Filter state + const [filterState, setFilterState] = useState(DEFAULT_ISSUES_FILTER_STATE); + const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore(); // Model override for validation @@ -44,6 +53,37 @@ export function GitHubIssuesView() { onShowValidationDialogChange: setShowValidationDialog, }); + // Combine all issues for filtering + const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]); + + // Apply filter to issues + const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations); + + // Filter issues based on matched results + const filteredOpenIssues = useMemo( + () => openIssues.filter((issue) => filterResult.matchedIssueNumbers.has(issue.number)), + [openIssues, filterResult.matchedIssueNumbers] + ); + + const filteredClosedIssues = useMemo( + () => closedIssues.filter((issue) => filterResult.matchedIssueNumbers.has(issue.number)), + [closedIssues, filterResult.matchedIssueNumbers] + ); + + // Filter state change handlers + const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => { + setFilterState((prev) => ({ ...prev, stateFilter })); + }, []); + + const handleLabelsChange = useCallback((selectedLabels: string[]) => { + setFilterState((prev) => ({ ...prev, selectedLabels })); + }, []); + + // Clear all filters to default state + const handleClearFilters = useCallback(() => { + setFilterState(DEFAULT_ISSUES_FILTER_STATE); + }, []); + // Get current branch from selected worktree const currentBranch = useMemo(() => { if (!currentProject?.path) return ''; @@ -130,7 +170,10 @@ export function GitHubIssuesView() { return ; } - const totalIssues = openIssues.length + closedIssues.length; + const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length; + const totalUnfilteredIssues = openIssues.length + closedIssues.length; + const isFilteredEmpty = + totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter; return (
@@ -143,10 +186,21 @@ export function GitHubIssuesView() { > {/* Header */} {/* Issues List */} @@ -154,15 +208,35 @@ export function GitHubIssuesView() { {totalIssues === 0 ? (
- + {isFilteredEmpty ? ( + + ) : ( + + )}
-

No Issues

-

This repository has no issues yet.

+

+ {isFilteredEmpty ? 'No Matching Issues' : 'No Issues'} +

+

+ {isFilteredEmpty + ? 'No issues match your current filters.' + : 'This repository has no issues yet.'} +

+ {isFilteredEmpty && ( + + )}
) : (
{/* Open Issues */} - {openIssues.map((issue) => ( + {filteredOpenIssues.map((issue) => ( 0 && ( + {filteredClosedIssues.length > 0 && ( <>
- Closed Issues ({closedIssues.length}) + Closed Issues ({filteredClosedIssues.length})
- {closedIssues.map((issue) => ( + {filteredClosedIssues.map((issue) => ( void; + /** Callback when labels selection changes */ + onLabelsChange: (labels: string[]) => void; + /** Whether the controls are disabled (e.g., during loading) */ + disabled?: boolean; + /** Whether to use compact layout (stacked vertically) */ + compact?: boolean; + /** Additional class name for the container */ + className?: string; +} + +/** Human-readable labels for state filter options */ +const STATE_FILTER_LABELS: Record = { + open: 'Open', + closed: 'Closed', + all: 'All', +}; + +export function IssuesFilterControls({ + stateFilter, + selectedLabels, + availableLabels, + onStateFilterChange, + onLabelsChange, + disabled = false, + compact = false, + className, +}: IssuesFilterControlsProps) { + /** + * Handles toggling a label in the selection. + * If the label is already selected, it removes it; otherwise, it adds it. + */ + const handleLabelToggle = (label: string) => { + const isSelected = selectedLabels.includes(label); + if (isSelected) { + onLabelsChange(selectedLabels.filter((l) => l !== label)); + } else { + onLabelsChange([...selectedLabels, label]); + } + }; + + /** + * Clears all selected labels. + */ + const handleClearLabels = () => { + onLabelsChange([]); + }; + + const hasSelectedLabels = selectedLabels.length > 0; + const hasAvailableLabels = availableLabels.length > 0; + + return ( +
+ {/* Filter Controls Row */} +
+ {/* State Filter Select */} + + + {/* Labels Filter Dropdown */} + + + + + + + Filter by label + {hasSelectedLabels && ( + + )} + + + {availableLabels.map((label) => ( + handleLabelToggle(label)} + onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing + > + {label} + + ))} + {!hasAvailableLabels && ( +
No labels available
+ )} +
+
+
+ + {/* Selected Labels Display - shown on separate row */} + {hasSelectedLabels && ( +
+ {selectedLabels.slice(0, compact ? 2 : 3).map((label) => ( + handleLabelToggle(label)} + > + {label} + + + ))} + {selectedLabels.length > (compact ? 2 : 3) && ( + + +{selectedLabels.length - (compact ? 2 : 3)} more + + )} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx index 5529b30c..1c58bbe4 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -1,38 +1,100 @@ import { CircleDot, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import type { IssuesStateFilter } from '../types'; +import { IssuesFilterControls } from './issues-filter-controls'; interface IssuesListHeaderProps { openCount: number; closedCount: number; + /** Total open issues count (unfiltered) - used to show "X of Y" when filtered */ + totalOpenCount?: number; + /** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */ + totalClosedCount?: number; + /** Whether any filter is currently active */ + hasActiveFilter?: boolean; refreshing: boolean; onRefresh: () => void; + /** Whether the list is in compact mode (e.g., when detail panel is open) */ + compact?: boolean; + /** Optional filter state and handlers - when provided, filter controls are rendered */ + filterProps?: { + stateFilter: IssuesStateFilter; + selectedLabels: string[]; + availableLabels: string[]; + onStateFilterChange: (filter: IssuesStateFilter) => void; + onLabelsChange: (labels: string[]) => void; + }; } export function IssuesListHeader({ openCount, closedCount, + totalOpenCount, + totalClosedCount, + hasActiveFilter = false, refreshing, onRefresh, + compact = false, + filterProps, }: IssuesListHeaderProps) { const totalIssues = openCount + closedCount; + // Format the counts subtitle based on filter state + const getCountsSubtitle = () => { + if (totalIssues === 0) { + return hasActiveFilter ? 'No matching issues' : 'No issues found'; + } + + // When filters are active and we have total counts, show "X of Y" format + if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) { + const openText = + openCount === totalOpenCount + ? `${openCount} open` + : `${openCount} of ${totalOpenCount} open`; + const closedText = + closedCount === totalClosedCount + ? `${closedCount} closed` + : `${closedCount} of ${totalClosedCount} closed`; + return `${openText}, ${closedText}`; + } + + // Default format when no filters active + return `${openCount} open, ${closedCount} closed`; + }; + return ( -
-
-
- -
-
-

Issues

-

- {totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`} -

+
+ {/* Top row: Title and refresh button */} +
+
+
+ +
+
+

Issues

+

{getCountsSubtitle()}

+
+
- + + {/* Filter controls row (optional) */} + {filterProps && ( +
+ +
+ )}
); } diff --git a/apps/ui/src/components/views/github-issues-view/hooks/index.ts b/apps/ui/src/components/views/github-issues-view/hooks/index.ts index 57b78868..a03332ad 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/index.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/index.ts @@ -1,3 +1,4 @@ export { useGithubIssues } from './use-github-issues'; export { useIssueValidation } from './use-issue-validation'; export { useIssueComments } from './use-issue-comments'; +export { useIssuesFilter } from './use-issues-filter'; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts new file mode 100644 index 00000000..3ab5f7bb --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts @@ -0,0 +1,239 @@ +import { useMemo } from 'react'; +import type { GitHubIssue, StoredValidation } from '@/lib/electron'; +import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types'; +import { isValidationStale } from '../utils'; + +/** + * Determines the validation status of an issue based on its cached validation. + */ +function getValidationStatus( + issueNumber: number, + cachedValidations: Map +): IssuesValidationStatus | null { + const validation = cachedValidations.get(issueNumber); + if (!validation) { + return 'not_validated'; + } + if (isValidationStale(validation.validatedAt)) { + return 'stale'; + } + return 'validated'; +} + +/** + * Checks if a search query matches an issue's searchable content. + * Searches through title and body (case-insensitive). + */ +function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean { + if (!normalizedQuery) return true; + + const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery); + const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery); + + return titleMatch || bodyMatch; +} + +/** + * Checks if an issue matches the state filter (open/closed/all). + * Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively. + */ +function matchesStateFilter( + issue: GitHubIssue, + stateFilter: IssuesFilterState['stateFilter'] +): boolean { + if (stateFilter === 'all') return true; + return issue.state.toLowerCase() === stateFilter; +} + +/** + * Checks if an issue matches any of the selected labels. + * Returns true if no labels are selected (no filter) or if any selected label matches. + */ +function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean { + if (selectedLabels.length === 0) return true; + + const issueLabels = issue.labels.map((l) => l.name); + return selectedLabels.some((label) => issueLabels.includes(label)); +} + +/** + * Checks if an issue matches any of the selected assignees. + * Returns true if no assignees are selected (no filter) or if any selected assignee matches. + */ +function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean { + if (selectedAssignees.length === 0) return true; + + const issueAssignees = issue.assignees?.map((a) => a.login) ?? []; + return selectedAssignees.some((assignee) => issueAssignees.includes(assignee)); +} + +/** + * Checks if an issue matches any of the selected milestones. + * Returns true if no milestones are selected (no filter) or if any selected milestone matches. + * Note: GitHub issues may not have milestone data in the current schema, this is a placeholder. + */ +function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean { + if (selectedMilestones.length === 0) return true; + + // GitHub issues in the current schema don't have milestone field + // This is a placeholder for future milestone support + // For now, issues with no milestone won't match if a milestone filter is active + return false; +} + +/** + * Checks if an issue matches the validation status filter. + */ +function matchesValidationStatus( + issue: GitHubIssue, + validationStatusFilter: IssuesValidationStatus | null, + cachedValidations: Map +): boolean { + if (!validationStatusFilter) return true; + + const status = getValidationStatus(issue.number, cachedValidations); + return status === validationStatusFilter; +} + +/** + * Extracts all unique labels from a list of issues. + */ +function extractAvailableLabels(issues: GitHubIssue[]): string[] { + const labelsSet = new Set(); + for (const issue of issues) { + for (const label of issue.labels) { + labelsSet.add(label.name); + } + } + return Array.from(labelsSet).sort(); +} + +/** + * Extracts all unique assignees from a list of issues. + */ +function extractAvailableAssignees(issues: GitHubIssue[]): string[] { + const assigneesSet = new Set(); + for (const issue of issues) { + for (const assignee of issue.assignees ?? []) { + assigneesSet.add(assignee.login); + } + } + return Array.from(assigneesSet).sort(); +} + +/** + * Extracts all unique milestones from a list of issues. + * Note: Currently returns empty array as milestone is not in the GitHubIssue schema. + */ +function extractAvailableMilestones(_issues: GitHubIssue[]): string[] { + // GitHub issues in the current schema don't have milestone field + // This is a placeholder for future milestone support + return []; +} + +/** + * Determines if any filter is currently active. + */ +function hasActiveFilterCheck(filterState: IssuesFilterState): boolean { + const { + searchQuery, + stateFilter, + selectedLabels, + selectedAssignees, + selectedMilestones, + validationStatusFilter, + } = filterState; + + // Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes + // Only 'closed' or 'all' are considered active filters + const hasStateFilter = stateFilter !== 'open'; + const hasSearchQuery = searchQuery.trim().length > 0; + const hasLabelFilter = selectedLabels.length > 0; + const hasAssigneeFilter = selectedAssignees.length > 0; + const hasMilestoneFilter = selectedMilestones.length > 0; + const hasValidationFilter = validationStatusFilter !== null; + + return ( + hasSearchQuery || + hasStateFilter || + hasLabelFilter || + hasAssigneeFilter || + hasMilestoneFilter || + hasValidationFilter + ); +} + +/** + * Hook to filter GitHub issues based on the current filter state. + * + * This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues. + * It computes matched issues and extracts available filter options from all issues. + * + * @param issues - Combined array of all issues (open + closed) to filter + * @param filterState - Current filter state including search, labels, assignees, etc. + * @param cachedValidations - Map of issue numbers to their cached validation results + * @returns Filter result containing matched issue numbers and available filter options + */ +export function useIssuesFilter( + issues: GitHubIssue[], + filterState: IssuesFilterState, + cachedValidations: Map = new Map() +): IssuesFilterResult { + const { + searchQuery, + stateFilter, + selectedLabels, + selectedAssignees, + selectedMilestones, + validationStatusFilter, + } = filterState; + + return useMemo(() => { + // Extract available options from all issues (for filter dropdown population) + const availableLabels = extractAvailableLabels(issues); + const availableAssignees = extractAvailableAssignees(issues); + const availableMilestones = extractAvailableMilestones(issues); + + // Check if any filter is active + const hasActiveFilter = hasActiveFilterCheck(filterState); + + // Normalize search query for case-insensitive matching + const normalizedQuery = searchQuery.toLowerCase().trim(); + + // Filter issues based on all criteria + const matchedIssueNumbers = new Set(); + + for (const issue of issues) { + // All conditions must be true for a match + const matchesAllFilters = + matchesSearchQuery(issue, normalizedQuery) && + matchesStateFilter(issue, stateFilter) && + matchesLabels(issue, selectedLabels) && + matchesAssignees(issue, selectedAssignees) && + matchesMilestones(issue, selectedMilestones) && + matchesValidationStatus(issue, validationStatusFilter, cachedValidations); + + if (matchesAllFilters) { + matchedIssueNumbers.add(issue.number); + } + } + + return { + matchedIssueNumbers, + availableLabels, + availableAssignees, + availableMilestones, + hasActiveFilter, + matchedCount: matchedIssueNumbers.size, + }; + }, [ + issues, + searchQuery, + stateFilter, + selectedLabels, + selectedAssignees, + selectedMilestones, + validationStatusFilter, + cachedValidations, + ]); +} diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts index 7ea799d9..d2986c16 100644 --- a/apps/ui/src/components/views/github-issues-view/types.ts +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -1,6 +1,111 @@ import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron'; import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types'; +// ============================================================================ +// Issues Filter State Types +// ============================================================================ + +/** + * Available sort columns for issues list + */ +export const ISSUES_SORT_COLUMNS = [ + 'title', + 'created_at', + 'updated_at', + 'comments', + 'number', +] as const; + +export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number]; + +/** + * Sort direction options + */ +export type IssuesSortDirection = 'asc' | 'desc'; + +/** + * Available issue state filter values + */ +export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const; + +export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number]; + +/** + * Validation status filter values for filtering issues by validation state + */ +export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const; + +export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number]; + +/** + * Sort configuration for issues list + */ +export interface IssuesSortConfig { + column: IssuesSortColumn; + direction: IssuesSortDirection; +} + +/** + * Main filter state interface for the GitHub Issues view + * + * This interface defines all filterable/sortable state for the issues list. + * It follows the same pattern as GraphFilterState but is tailored for GitHub issues. + */ +export interface IssuesFilterState { + /** Search query for filtering by issue title or body */ + searchQuery: string; + /** Filter by issue state (open/closed/all) */ + stateFilter: IssuesStateFilter; + /** Filter by selected labels (matches any) */ + selectedLabels: string[]; + /** Filter by selected assignees (matches any) */ + selectedAssignees: string[]; + /** Filter by selected milestones (matches any) */ + selectedMilestones: string[]; + /** Filter by validation status */ + validationStatusFilter: IssuesValidationStatus | null; + /** Current sort configuration */ + sortConfig: IssuesSortConfig; +} + +/** + * Result of applying filters to the issues list + */ +export interface IssuesFilterResult { + /** Set of issue numbers that match the current filters */ + matchedIssueNumbers: Set; + /** Available labels from all issues (for filter dropdown population) */ + availableLabels: string[]; + /** Available assignees from all issues (for filter dropdown population) */ + availableAssignees: string[]; + /** Available milestones from all issues (for filter dropdown population) */ + availableMilestones: string[]; + /** Whether any filter is currently active */ + hasActiveFilter: boolean; + /** Total count of matched issues */ + matchedCount: number; +} + +/** + * Default values for IssuesFilterState + */ +export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = { + searchQuery: '', + stateFilter: 'open', + selectedLabels: [], + selectedAssignees: [], + selectedMilestones: [], + validationStatusFilter: null, + sortConfig: { + column: 'updated_at', + direction: 'desc', + }, +}; + +// ============================================================================ +// Component Props Types +// ============================================================================ + export interface IssueRowProps { issue: GitHubIssue; isSelected: boolean; diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts index 8a354b3d..4627f2b3 100644 --- a/apps/ui/src/hooks/index.ts +++ b/apps/ui/src/hooks/index.ts @@ -2,6 +2,7 @@ export { useAutoMode } from './use-auto-mode'; export { useBoardBackgroundSettings } from './use-board-background-settings'; export { useElectronAgent } from './use-electron-agent'; export { useGuidedPrompts } from './use-guided-prompts'; +export { useIssuesFilter } from '@/components/views/github-issues-view/hooks/use-issues-filter'; export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; export { useMessageQueue } from './use-message-queue'; export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection'; From c0d64bc9940bc76901f3f3b0fe44c79bf52fcc7e Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 16 Jan 2026 21:05:58 +0100 Subject: [PATCH 2/2] fix: adress pr comments --- .../components/views/github-issues-view.tsx | 26 +++++++------ .../components/issues-filter-controls.tsx | 39 ++++++++++++------- .../hooks/use-issues-filter.ts | 11 +++--- .../views/github-issues-view/types.ts | 4 +- apps/ui/src/hooks/index.ts | 1 - 5 files changed, 48 insertions(+), 33 deletions(-) diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index a34f1225..03275b02 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -56,19 +56,23 @@ export function GitHubIssuesView() { // Combine all issues for filtering const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]); - // Apply filter to issues + // Apply filter to issues - now returns matched issues directly for better performance const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations); - // Filter issues based on matched results - const filteredOpenIssues = useMemo( - () => openIssues.filter((issue) => filterResult.matchedIssueNumbers.has(issue.number)), - [openIssues, filterResult.matchedIssueNumbers] - ); - - const filteredClosedIssues = useMemo( - () => closedIssues.filter((issue) => filterResult.matchedIssueNumbers.has(issue.number)), - [closedIssues, filterResult.matchedIssueNumbers] - ); + // Separate filtered issues by state - this is O(n) but now only done once + // since filterResult.matchedIssues already contains the filtered issues + const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => { + const open: typeof openIssues = []; + const closed: typeof closedIssues = []; + for (const issue of filterResult.matchedIssues) { + if (issue.state.toLowerCase() === 'open') { + open.push(issue); + } else { + closed.push(issue); + } + } + return { filteredOpenIssues: open, filteredClosedIssues: closed }; + }, [filterResult.matchedIssues]); // Filter state change handlers const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => { diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-filter-controls.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-filter-controls.tsx index f2378ef0..475a32d5 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-filter-controls.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-filter-controls.tsx @@ -20,6 +20,11 @@ import { cn } from '@/lib/utils'; import type { IssuesStateFilter } from '../types'; import { ISSUES_STATE_FILTER_OPTIONS } from '../types'; +/** Maximum number of labels to display before showing "+N more" in normal layout */ +const VISIBLE_LABELS_LIMIT = 3; +/** Maximum number of labels to display before showing "+N more" in compact layout */ +const VISIBLE_LABELS_LIMIT_COMPACT = 2; + interface IssuesFilterControlsProps { /** Current state filter value */ stateFilter: IssuesStateFilter; @@ -156,21 +161,27 @@ export function IssuesFilterControls({ {/* Selected Labels Display - shown on separate row */} {hasSelectedLabels && (
- {selectedLabels.slice(0, compact ? 2 : 3).map((label) => ( - handleLabelToggle(label)} - > - {label} - - - ))} - {selectedLabels.length > (compact ? 2 : 3) && ( + {selectedLabels + .slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) + .map((label) => ( + handleLabelToggle(label)} + > + {label} + + + ))} + {selectedLabels.length > + (compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && ( - +{selectedLabels.length - (compact ? 2 : 3)} more + + + {selectedLabels.length - + (compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '} + more )}
diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts index 3ab5f7bb..987e890a 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts @@ -200,8 +200,9 @@ export function useIssuesFilter( // Normalize search query for case-insensitive matching const normalizedQuery = searchQuery.toLowerCase().trim(); - // Filter issues based on all criteria - const matchedIssueNumbers = new Set(); + // Filter issues based on all criteria - return matched issues directly + // This eliminates the redundant O(n) filtering operation in the consuming component + const matchedIssues: GitHubIssue[] = []; for (const issue of issues) { // All conditions must be true for a match @@ -214,17 +215,17 @@ export function useIssuesFilter( matchesValidationStatus(issue, validationStatusFilter, cachedValidations); if (matchesAllFilters) { - matchedIssueNumbers.add(issue.number); + matchedIssues.push(issue); } } return { - matchedIssueNumbers, + matchedIssues, availableLabels, availableAssignees, availableMilestones, hasActiveFilter, - matchedCount: matchedIssueNumbers.size, + matchedCount: matchedIssues.length, }; }, [ issues, diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts index d2986c16..a66e3a96 100644 --- a/apps/ui/src/components/views/github-issues-view/types.ts +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -72,8 +72,8 @@ export interface IssuesFilterState { * Result of applying filters to the issues list */ export interface IssuesFilterResult { - /** Set of issue numbers that match the current filters */ - matchedIssueNumbers: Set; + /** Array of GitHubIssue objects that match the current filters */ + matchedIssues: GitHubIssue[]; /** Available labels from all issues (for filter dropdown population) */ availableLabels: string[]; /** Available assignees from all issues (for filter dropdown population) */ diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts index 4627f2b3..8a354b3d 100644 --- a/apps/ui/src/hooks/index.ts +++ b/apps/ui/src/hooks/index.ts @@ -2,7 +2,6 @@ export { useAutoMode } from './use-auto-mode'; export { useBoardBackgroundSettings } from './use-board-background-settings'; export { useElectronAgent } from './use-electron-agent'; export { useGuidedPrompts } from './use-guided-prompts'; -export { useIssuesFilter } from '@/components/views/github-issues-view/hooks/use-issues-filter'; export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; export { useMessageQueue } from './use-message-queue'; export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection';