From 6237f1a0fe64236c1164185a26693c8d0bb42d6b Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 16 Jan 2026 20:56:23 +0100 Subject: [PATCH 1/3] 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/3] 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'; From c2430e5bd3b1c6439389d4ba29b64b2006b18426 Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 16 Jan 2026 21:53:53 +0100 Subject: [PATCH 3/3] feat: enhance PTY handling for Windows in ClaudeUsageService and TerminalService - Added detection for Electron environment to improve compatibility with Windows PTY processes. - Implemented winpty fallback for ConPTY failures, ensuring robust terminal session creation in Electron and other contexts. - Updated error handling to provide clearer messages for authentication and terminal access issues. - Refined usage data detection logic to avoid false positives, improving the accuracy of usage reporting. These changes aim to enhance the reliability and user experience of terminal interactions on Windows, particularly in Electron applications. --- .../src/services/claude-usage-service.ts | 158 +++++++++++++----- apps/server/src/services/terminal-service.ts | 63 ++++++- 2 files changed, 180 insertions(+), 41 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 35c00a20..aebed98b 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -22,6 +22,13 @@ export class ClaudeUsageService { private timeout = 30000; // 30 second timeout private isWindows = os.platform() === 'win32'; private isLinux = os.platform() === 'linux'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + // When in Electron, always use winpty to avoid ConPTY's AttachConsole errors + private isElectron = + !!(process.versions && (process.versions as Record).electron) || + !!process.env.ELECTRON_RUN_AS_NODE; + private useConptyFallback = false; // Track if we need to use winpty fallback on Windows /** * Kill a PTY process with platform-specific handling. @@ -197,30 +204,87 @@ export class ClaudeUsageService { ? ['/c', 'claude', '--add-dir', workingDirectory] : ['-c', `claude --add-dir "${workingDirectory}"`]; + // Using 'any' for ptyProcess because node-pty types don't include 'killed' property + // eslint-disable-next-line @typescript-eslint/no-explicit-any let ptyProcess: any = null; + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + '[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)' + ); + } + try { - ptyProcess = pty.spawn(shell, args, { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - } as Record, - }); + ptyProcess = pty.spawn(shell, args, ptyOptions); } catch (spawnError) { const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); - logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); - // Return a user-friendly error instead of crashing - reject( - new Error( - `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` - ) - ); - return; + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn( + '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback' + ); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, args, ptyOptions); + logger.info( + '[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback' + ); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error( + '[executeClaudeUsageCommandPty] Winpty fallback also failed:', + fallbackMessage + ); + reject( + new Error( + `Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage); + reject( + new Error( + `Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } } const timeoutId = setTimeout(() => { @@ -260,12 +324,19 @@ export class ClaudeUsageService { const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); // Check for specific authentication/permission errors - if ( - cleanOutput.includes('OAuth token does not meet scope requirement') || - cleanOutput.includes('permission_error') || - cleanOutput.includes('token_expired') || - cleanOutput.includes('authentication_error') - ) { + // Must be very specific to avoid false positives from garbled terminal encoding + // Removed permission_error check as it was causing false positives with winpty encoding + const authChecks = { + oauth: cleanOutput.includes('OAuth token does not meet scope requirement'), + tokenExpired: cleanOutput.includes('token_expired'), + // Only match if it looks like a JSON API error response + authError: + cleanOutput.includes('"type":"authentication_error"') || + cleanOutput.includes('"type": "authentication_error"'), + }; + const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError; + + if (hasAuthError) { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { @@ -281,11 +352,16 @@ export class ClaudeUsageService { } // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) - if ( - !hasSeenUsageData && - (cleanOutput.includes('Current session') || - (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) - ) { + // Also check for percentage patterns that appear in usage output + const hasUsageIndicators = + cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || + // Additional patterns for winpty - look for percentage patterns + /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || + cleanOutput.includes('Resets in') || + cleanOutput.includes('Current week'); + + if (!hasSeenUsageData && hasUsageIndicators) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { @@ -324,10 +400,18 @@ export class ClaudeUsageService { } // Detect REPL prompt and send /usage command - if ( - !hasSentCommand && - (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) - ) { + // On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators + const isReplReady = + cleanOutput.includes('❯') || + cleanOutput.includes('? for shortcuts') || + // Fallback for winpty garbled encoding - detect CLI welcome screen elements + (cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) || + (cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) || + // Detect model indicator which appears when REPL is ready + (cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) || + (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')); + + if (!hasSentCommand && isReplReady) { hasSentCommand = true; // Wait for REPL to fully settle setTimeout(() => { @@ -364,11 +448,9 @@ export class ClaudeUsageService { if (settled) return; settled = true; - if ( - output.includes('token_expired') || - output.includes('authentication_error') || - output.includes('permission_error') - ) { + // Check for auth errors - must be specific to avoid false positives + // Removed permission_error check as it was causing false positives with winpty encoding + if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) { reject(new Error("Authentication required - please run 'claude login'")); return; } diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index bd4481a8..f83aaede 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -71,6 +71,12 @@ export class TerminalService extends EventEmitter { private dataCallbacks: Set = new Set(); private exitCallbacks: Set = new Set(); private isWindows = os.platform() === 'win32'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + private isElectron = + !!(process.versions && (process.versions as Record).electron) || + !!process.env.ELECTRON_RUN_AS_NODE; + private useConptyFallback = false; // Track if we need to use winpty fallback on Windows /** * Kill a PTY process with platform-specific handling. @@ -339,13 +345,60 @@ export class TerminalService extends EventEmitter { logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); - const ptyProcess = pty.spawn(shell, shellArgs, { + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { name: 'xterm-256color', cols: options.cols || 80, rows: options.rows || 24, cwd, env, - }); + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + `[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)` + ); + } + + let ptyProcess: pty.IPty; + try { + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + } catch (spawnError) { + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed:`, errorMessage); + return null; + } + } const session: TerminalSession = { id, @@ -409,7 +462,11 @@ export class TerminalService extends EventEmitter { // Handle exit ptyProcess.onExit(({ exitCode }) => { - logger.info(`Session ${id} exited with code ${exitCode}`); + const exitMessage = + exitCode === undefined || exitCode === null + ? 'Session terminated' + : `Session exited with code ${exitCode}`; + logger.info(`${exitMessage} (${id})`); this.sessions.delete(id); this.exitCallbacks.forEach((cb) => cb(id, exitCode)); this.emit('exit', id, exitCode);