Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
270 changes: 179 additions & 91 deletions src/renderer/src/components/TaskPage.tsx

Large diffs are not rendered by default.

44 changes: 8 additions & 36 deletions src/renderer/src/components/github/PRFilterDropdowns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
// own collapsed Filters dropdown so the toolbar stays uncluttered when nothing
// is set. Active filters surface as inline removable pills next to the button.
import React, { useMemo, useState } from 'react'
import { ListFilter, X } from 'lucide-react'
import { ListFilter } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { TaskFilterPill } from '@/components/task-filter-pill'
import { cn } from '@/lib/utils'
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import type { PickerOption } from '@/components/github/PRFilterPickers'
import type { PickerOption } from '@/components/task-filter-pickers'
import {
SectionDetail,
SectionMenu,
Expand Down Expand Up @@ -41,35 +42,6 @@ function userOptions(users: GitHubAssignableUser[]): PickerOption[] {
return users.map((u) => ({ key: u.login, primary: u.login, secondary: u.name ?? undefined }))
}

function ActivePill({
label,
value,
onClear
}: {
label: string
value: string
onClear: () => void
}): React.JSX.Element {
return (
<span className="inline-flex h-6 items-center gap-1 rounded-full border border-border/60 bg-muted/50 pl-2 pr-1 text-[11px] text-foreground">
<span className="text-muted-foreground">{label}:</span>
<span className="max-w-[160px] truncate font-medium">{value}</span>
<button
type="button"
aria-label={translate(
'auto.components.github.PRFilterDropdowns.8a2ffbf9b3',
'Remove {{value0}} filter',
{ value0: label }
)}
onClick={onClear}
className="rounded-full p-0.5 text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<X className="size-3" />
</button>
</span>
)
}

export default function PRFilterDropdowns({
parsed,
kind,
Expand Down Expand Up @@ -242,28 +214,28 @@ export default function PRFilterDropdowns({
</PopoverContent>
</Popover>
{statusPillValue ? (
<ActivePill
<TaskFilterPill
label={translate('auto.components.github.PRFilterDropdowns.13b3ac0a84', 'Status')}
value={statusPillValue}
onClear={() => onChange({ state: 'open', draft: false })}
/>
) : null}
{parsed.author ? (
<ActivePill
<TaskFilterPill
label={translate('auto.components.github.PRFilterDropdowns.01f3f3d161', 'Author')}
value={parsed.author}
onClear={() => onChange({ author: null })}
/>
) : null}
{parsed.labels.length > 0 ? (
<ActivePill
<TaskFilterPill
label={translate('auto.components.github.PRFilterDropdowns.9d0f2eda6d', 'Label')}
value={parsed.labels.length === 1 ? parsed.labels[0] : `${parsed.labels.length} labels`}
onClear={() => onChange({ labels: [] })}
/>
) : null}
{reviewerActive ? (
<ActivePill
<TaskFilterPill
label={
reviewerKind === 'reviewed-by'
? translate('auto.components.github.PRFilterDropdowns.7f1ba66c3e', 'Reviewed by')
Expand All @@ -274,7 +246,7 @@ export default function PRFilterDropdowns({
/>
) : null}
{parsed.assignee ? (
<ActivePill
<TaskFilterPill
label={translate('auto.components.github.PRFilterDropdowns.979be3cf6b', 'Assignee')}
value={parsed.assignee}
onClear={() => onChange({ assignee: null })}
Expand Down
54 changes: 0 additions & 54 deletions src/renderer/src/components/github/PRFilterPickers.test.ts

This file was deleted.

57 changes: 14 additions & 43 deletions src/renderer/src/components/github/PRFilterSections.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React from 'react'
import { ChevronRight } from 'lucide-react'
import {
MultiSelectList,
SingleSelectList,
type PickerOption
} from '@/components/github/PRFilterPickers'
} from '@/components/task-filter-pickers'
import {
FilterSectionBackButton,
FilterSectionMenu,
type FilterSectionRow
} from '@/components/task-filter-section-menu'
import { cn } from '@/lib/utils'
import type { ParsedTaskQuery } from '../../../../shared/task-query'
import { translate } from '@/i18n/i18n'
Expand Down Expand Up @@ -168,7 +172,7 @@ export function SectionMenu({
onClearAll: (() => void) | null
}): React.JSX.Element {
const status = statusLabel(parsed)
const rows: { key: SectionKey; label: string; value: string | null }[] = [
const rows: FilterSectionRow<SectionKey>[] = [
{
key: 'status',
label: translate('auto.components.github.PRFilterSections.764a0b4ce1', 'Status'),
Expand Down Expand Up @@ -206,38 +210,12 @@ export function SectionMenu({
]
const subject = kind === 'prs' ? 'pull requests' : 'issues'
return (
<div className="py-1 text-xs">
<div className="px-3 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')}
{subject}
</div>
{rows.map((row) => (
<button
key={row.key}
type="button"
onClick={() => onPick(row.key)}
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left transition hover:bg-muted/50"
>
<span>{row.label}</span>
<span className="flex items-center gap-1 text-muted-foreground">
{row.value ? <span className="max-w-[140px] truncate">{row.value}</span> : null}
<ChevronRight className="size-3.5" />
</span>
</button>
))}
{onClearAll ? (
<>
<div className="my-1 h-px bg-border" />
<button
type="button"
onClick={onClearAll}
className="w-full px-3 py-1.5 text-left text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
>
{translate('auto.components.github.PRFilterSections.30ebb6ca44', 'Clear all filters')}
</button>
</>
) : null}
</div>
<FilterSectionMenu
heading={`${translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} ${subject}`}
rows={rows}
onPick={onPick}
onClearAll={onClearAll}
/>
)
}

Expand Down Expand Up @@ -274,14 +252,7 @@ export function SectionDetail({
}): React.JSX.Element {
return (
<div>
<button
type="button"
onClick={onBack}
className="flex w-full items-center gap-1 border-b border-border px-3 py-1.5 text-[11px] text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
>
<ChevronRight className="size-3 rotate-180" />
{translate('auto.components.github.PRFilterSections.b69fa4fa20', 'Back')}
</button>
<FilterSectionBackButton onBack={onBack} />
{section === 'status' ? (
<StatusSection parsed={parsed} kind={kind} onSelect={onSelect} />
) : null}
Expand Down
115 changes: 115 additions & 0 deletions src/renderer/src/components/linear-assignee-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import type { LinearIssue } from '../../../shared/types'
import {
UNASSIGNED_LINEAR_ASSIGNEE_ID,
collectLinearAssigneeOptions,
getLinearAssigneeTriggerLabel,
issueMatchesLinearAssigneeSelection,
reconcileLinearAssigneeSelection
} from './linear-assignee-filter'

function issue(id: string, assignee?: { id: string; displayName: string }): LinearIssue {
return {
id,
identifier: id.toUpperCase(),
title: `Issue ${id}`,
url: `https://linear.app/org/issue/${id}`,
state: { name: 'Todo', type: 'unstarted', color: '#888888' },
team: { id: 'team-1', name: 'Engineering', key: 'ENG' },
labels: [],
labelIds: [],
assignee,
priority: 0,
updatedAt: '2026-01-01T00:00:00.000Z'
}
}

const ada = { id: 'user-ada', displayName: 'Ada' }
const grace = { id: 'user-grace', displayName: 'Grace' }

describe('collectLinearAssigneeOptions', () => {
it('dedupes assignees, sorts by name, and appends unassigned last', () => {
const options = collectLinearAssigneeOptions([
issue('a', grace),
issue('b', ada),
issue('c', grace),
issue('d')
])

expect(options.map((option) => option.id)).toEqual([
'user-ada',
'user-grace',
UNASSIGNED_LINEAR_ASSIGNEE_ID
])
})

it('omits the unassigned option when every issue has an assignee', () => {
const options = collectLinearAssigneeOptions([issue('a', ada)])
expect(options.map((option) => option.id)).toEqual(['user-ada'])
})
})

describe('issueMatchesLinearAssigneeSelection', () => {
it('treats an empty selection as all assignees', () => {
expect(issueMatchesLinearAssigneeSelection(issue('a', ada), new Set())).toBe(true)
expect(issueMatchesLinearAssigneeSelection(issue('b'), new Set())).toBe(true)
})

it('matches by assignee id', () => {
expect(issueMatchesLinearAssigneeSelection(issue('a', ada), new Set(['user-ada']))).toBe(true)
expect(issueMatchesLinearAssigneeSelection(issue('a', grace), new Set(['user-ada']))).toBe(
false
)
})

it('matches unassigned issues via the unassigned sentinel', () => {
expect(
issueMatchesLinearAssigneeSelection(issue('a'), new Set([UNASSIGNED_LINEAR_ASSIGNEE_ID]))
).toBe(true)
expect(
issueMatchesLinearAssigneeSelection(issue('a', ada), new Set([UNASSIGNED_LINEAR_ASSIGNEE_ID]))
).toBe(false)
})
})

describe('reconcileLinearAssigneeSelection', () => {
const options = collectLinearAssigneeOptions([issue('a', ada), issue('b')])

it('keeps an empty selection as-is', () => {
const selection = new Set<string>()
expect(reconcileLinearAssigneeSelection(options, selection)).toBe(selection)
})

it('keeps selections whose ids are all still present', () => {
const selection = new Set(['user-ada', UNASSIGNED_LINEAR_ASSIGNEE_ID])
expect(reconcileLinearAssigneeSelection(options, selection)).toBe(selection)
})

it('drops ids that no longer appear in the fetched issues', () => {
const next = reconcileLinearAssigneeSelection(options, new Set(['user-ada', 'user-grace']))
expect(Array.from(next)).toEqual(['user-ada'])
})

it('falls back to all assignees when every selected id is stale', () => {
const next = reconcileLinearAssigneeSelection(options, new Set(['user-grace']))
expect(next.size).toBe(0)
})
})

describe('getLinearAssigneeTriggerLabel', () => {
const options = collectLinearAssigneeOptions([issue('a', ada), issue('b', grace), issue('c')])

it('shows the generic label when nothing is selected', () => {
expect(getLinearAssigneeTriggerLabel(options, new Set())).toBe('Assignee')
})

it('shows the display name for a single selection', () => {
expect(getLinearAssigneeTriggerLabel(options, new Set(['user-ada']))).toBe('Ada')
})

it('shows a count for multiple selections', () => {
expect(getLinearAssigneeTriggerLabel(options, new Set(['user-ada', 'user-grace']))).toBe(
'2 assignees'
)
})
})
Loading