diff --git a/src/renderer/src/components/linear-issue-attribute-filter-coverage-notice.tsx b/src/renderer/src/components/linear-issue-attribute-filter-coverage-notice.tsx
new file mode 100644
index 00000000000..6b9a793d820
--- /dev/null
+++ b/src/renderer/src/components/linear-issue-attribute-filter-coverage-notice.tsx
@@ -0,0 +1,59 @@
+// Why: the transport id cap trims a deduplicated facet row silently, and any surviving id
+// keeps that row checked — so the picker has to say how many per-team ids it really applies.
+import React from 'react'
+import { translate } from '@/i18n/i18n'
+import { linearMetadataGroupCoverage } from './linear-issue-attribute-filter-team-ids'
+
+type LinearCoverageFacet = 'status' | 'labels'
+
+function shortfallMessage(facet: LinearCoverageFacet, applied: number, intended: number): string {
+ const counts = { value0: applied, value1: intended }
+ return facet === 'status'
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-coverage-notice.statusPartialTeamCoverage',
+ 'Filtering {{value0}} of {{value1}} team statuses — issues from the remaining teams are not included.',
+ counts
+ )
+ : translate(
+ 'auto.components.linear-issue-attribute-filter-coverage-notice.labelsPartialTeamCoverage',
+ 'Filtering {{value0}} of {{value1}} team labels — issues from the remaining teams are not included.',
+ counts
+ )
+}
+
+/** Why: a row the cap could not fit leaves no trace, so the spent budget is what we can state. */
+function idLimitMessage(facet: LinearCoverageFacet, max: number): string {
+ return facet === 'status'
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-coverage-notice.statusAtIdLimit',
+ 'Filtering the most this can carry: {{value0}} team statuses. Rows picked past that are left out.',
+ { value0: max }
+ )
+ : translate(
+ 'auto.components.linear-issue-attribute-filter-coverage-notice.labelsAtIdLimit',
+ 'Filtering the most this can carry: {{value0}} team labels. Rows picked past that are left out.',
+ { value0: max }
+ )
+}
+
+export function LinearFacetCoverageNotice({
+ facet,
+ options,
+ selectedIds,
+ max
+}: {
+ facet: LinearCoverageFacet
+ options: readonly { key: string; ids: readonly string[] }[]
+ selectedIds: readonly string[]
+ max: number
+}): React.JSX.Element | null {
+ const { applied, intended, atLimit } = linearMetadataGroupCoverage(options, selectedIds, max)
+ if (intended <= applied && !atLimit) {
+ return null
+ }
+ return (
+
+ {intended > applied ? shortfallMessage(facet, applied, intended) : idLimitMessage(facet, max)}
+
+ )
+}
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx
index 2fc0bf5fd61..fb3e2cc32a5 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx
+++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx
@@ -8,11 +8,13 @@ import {
clearLinearIssueAttributeFacet,
countLinearIssueAttributeFilters,
linearIssueAttributeFilterPillLabels
-} from './linear-issue-attribute-filter-sections'
+} from './linear-issue-attribute-filter-pills'
import {
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS,
type LinearIssueAttributeFilter
} from '../../../shared/linear/issue-attribute-filter'
+import { TooltipProvider } from '@/components/ui/tooltip'
import LinearIssueAttributeFilterDropdowns from './linear-issue-attribute-filter-dropdowns'
const metadataMocks = vi.hoisted(() => ({
@@ -84,7 +86,9 @@ describe('linear-issue-attribute-filter helpers', () => {
['fe-todo', 'Todo']
]),
memberNamesById: new Map(),
- labelNamesById: new Map()
+ labelNamesById: new Map(),
+ statusOptions: [{ key: 'be-todo', primary: 'Todo', ids: ['be-todo', 'fe-todo'] }],
+ labelOptions: []
})
expect(pills[0]?.value).toBe('Todo')
})
@@ -97,7 +101,9 @@ describe('linear-issue-attribute-filter helpers', () => {
['s2', 'In Progress']
]),
memberNamesById: new Map(),
- labelNamesById: new Map([['l1', 'Bug']])
+ labelNamesById: new Map([['l1', 'Bug']]),
+ statusOptions: [],
+ labelOptions: []
})
expect(pills.map((p) => p.key)).toEqual(['status', 'priority', 'assignee', 'labels'])
expect(pills[0]?.value).toContain('Todo')
@@ -260,40 +266,61 @@ const multiTeamStates = [
const teamBe: LinearTeam = { id: 'team-be', name: 'Backend', key: 'BE' }
const teamFe: LinearTeam = { id: 'team-fe', name: 'Frontend', key: 'FE' }
-function openStatusSection(onChange: (next: LinearIssueAttributeFilter) => void): void {
+function renderDropdowns(value: LinearIssueAttributeFilter): {
+ rerender: (next: LinearIssueAttributeFilter) => void
+ onChange: ReturnType
+} {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
+ const onChange = vi.fn()
- act(() => {
- root.render(
-
- )
- })
+ const rerender = (next: LinearIssueAttributeFilter): void => {
+ act(() => {
+ root.render(
+ // The app mounts one provider at its root; the pill's partial marker needs it.
+
+
+
+ )
+ })
+ }
+ rerender(value)
const trigger = container.querySelector('button')
act(() => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
+ return { rerender, onChange }
+}
- const statusButton = [...document.body.querySelectorAll('button')].find(
- (button) => button.textContent?.trim() === 'Status'
+// A section button carries its label plus its selection summary, so match on the label.
+function openSectionNamed(label: string): void {
+ const sectionButton = [...document.body.querySelectorAll('button')].find((button) =>
+ button.textContent?.trim().startsWith(label)
)
act(() => {
- statusButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ sectionButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
}
-function statusRowsNamed(name: string): HTMLElement[] {
+const emptyFilter: LinearIssueAttributeFilter = {
+ stateIds: [],
+ priorities: [],
+ assignee: null,
+ labelIds: []
+}
+
+function pickerRowsNamed(name: string): HTMLElement[] {
return [...document.body.querySelectorAll('[role="option"]')].filter(
(row) => row.textContent?.trim() === name
)
@@ -309,10 +336,11 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () =
error: null
}))
- openStatusSection(() => undefined)
+ renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
- expect(statusRowsNamed('Todo')).toHaveLength(1)
- expect(statusRowsNamed('Backlog')).toHaveLength(1)
+ expect(pickerRowsNamed('Todo')).toHaveLength(1)
+ expect(pickerRowsNamed('Backlog')).toHaveLength(1)
})
it('selects every team state id behind the picked status name', () => {
@@ -321,11 +349,10 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () =
loading: false,
error: null
}))
- const onChange = vi.fn()
-
- openStatusSection(onChange)
+ const { onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
act(() => {
- statusRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onChange).toHaveBeenCalledWith(
@@ -346,11 +373,10 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () =
loading: false,
error: null
}))
- const onChange = vi.fn()
-
- openStatusSection(onChange)
+ const { onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
act(() => {
- statusRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onChange.mock.calls[0]?.[0].stateIds).toHaveLength(
@@ -358,3 +384,198 @@ describe('LinearIssueAttributeFilterDropdowns status options across teams', () =
)
})
})
+
+function sameNamedMetadata(name: string, count: number): { id: string; name: string }[] {
+ return Array.from({ length: count }, (_unused, index) => ({ id: `team-${index}-${name}`, name }))
+}
+
+// Why: the bound keeps the row checked (any surviving id maps back to it), so without a
+// notice the picker claims team coverage the filter never had (#16879).
+describe('LinearIssueAttributeFilterDropdowns transport-cap coverage notice', () => {
+ it('reports the team statuses left out once a picked status exceeds the id cap', () => {
+ const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20
+ metadataMocks.useTeamsStates.mockImplementation(() => ({
+ data: sameNamedMetadata('todo', overCap),
+ loading: false,
+ error: null
+ }))
+
+ const { rerender, onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
+ act(() => {
+ pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter
+ expect(bounded.stateIds).toHaveLength(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS)
+ rerender(bounded)
+
+ expect(pickerRowsNamed('todo')).toHaveLength(1)
+ expect(document.body.textContent).toContain(
+ `Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS} of ${overCap} team statuses`
+ )
+ })
+
+ it('reports the shrunken coverage of a picked status when a second status is added', () => {
+ const perStatus = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20
+ metadataMocks.useTeamsStates.mockImplementation(() => ({
+ data: [...sameNamedMetadata('todo', perStatus), ...sameNamedMetadata('doing', perStatus)],
+ loading: false,
+ error: null
+ }))
+
+ const { rerender, onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
+ act(() => {
+ pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
+ act(() => {
+ pickerRowsNamed('doing')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ rerender(onChange.mock.calls[1]?.[0] as LinearIssueAttributeFilter)
+
+ expect(document.body.textContent).toContain(
+ `Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS} of ${perStatus * 2} team statuses`
+ )
+ })
+
+ it('stays silent while every team id behind the picked status is applied', () => {
+ metadataMocks.useTeamsStates.mockImplementation(() => ({
+ data: multiTeamStates,
+ loading: false,
+ error: null
+ }))
+
+ const { rerender, onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
+ act(() => {
+ pickerRowsNamed('Todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
+
+ expect(document.body.textContent).not.toContain('team statuses')
+ openSectionNamed('Back')
+ expect(document.body.textContent).not.toContain('partial')
+ })
+
+ // Why: the notice only exists inside the detail panel, but the surfaces a user works from
+ // after applying a filter are the collapsed section menu and the pill (#16879).
+ it('flags the trimmed status filter in the section menu and the pill', () => {
+ const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS + 20
+ metadataMocks.useTeamsStates.mockImplementation(() => ({
+ data: sameNamedMetadata('todo', overCap),
+ loading: false,
+ error: null
+ }))
+
+ const { rerender, onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
+ act(() => {
+ pickerRowsNamed('todo')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
+ openSectionNamed('Back')
+
+ expect(document.body.textContent).toContain('1 selected · partial')
+ // Why: the marker has to be reachable by keyboard and named for a screen reader,
+ // which a bare title attribute never was (#17342).
+ const pillMarkers = [...document.body.querySelectorAll('button')].filter(
+ (button) => button.textContent === 'partial'
+ )
+ expect(pillMarkers).toHaveLength(1)
+ expect(pillMarkers[0]?.getAttribute('data-slot')).toBe('tooltip-trigger')
+ })
+
+ // Why: the canonical id list is sorted before the cap slices it, so a picked row whose ids
+ // all sort last used to vanish outright — unchecked, uncounted, and with no notice at all.
+ it('keeps and counts a picked status whose ids all sort past the cap', () => {
+ const cap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
+ metadataMocks.useTeamsStates.mockImplementation(() => ({
+ data: [
+ ...Array.from({ length: cap }, (_unused, index) => ({
+ id: `a-${String(index).padStart(3, '0')}`,
+ name: 'Alpha'
+ })),
+ { id: 'z-000', name: 'Zeta' }
+ ],
+ loading: false,
+ error: null
+ }))
+
+ const { rerender, onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Status')
+ act(() => {
+ pickerRowsNamed('Alpha')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ rerender(onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter)
+ act(() => {
+ pickerRowsNamed('Zeta')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ const bounded = onChange.mock.calls[1]?.[0] as LinearIssueAttributeFilter
+ rerender(bounded)
+
+ expect(bounded.stateIds).toHaveLength(cap)
+ expect(bounded.stateIds).toContain('z-000')
+ expect(document.body.textContent).toContain(`Filtering ${cap} of ${cap + 1} team statuses`)
+ })
+
+ // Why: with more single-id status rows than the cap, the row the user clicks cannot fit at
+ // all — the picker used to check nothing and still report full coverage (#17342).
+ it('says the status filter is full instead of claiming coverage it cannot have', () => {
+ const cap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
+ const states = Array.from({ length: cap + 1 }, (_unused, index) => ({
+ id: `s-${String(index).padStart(3, '0')}`,
+ name: `Status ${String(index).padStart(3, '0')}`
+ }))
+ metadataMocks.useTeamsStates.mockImplementation(() => ({
+ data: states,
+ loading: false,
+ error: null
+ }))
+
+ const { rerender, onChange } = renderDropdowns({
+ ...emptyFilter,
+ stateIds: states.slice(0, cap).map((state) => state.id)
+ })
+ openSectionNamed('Status')
+ act(() => {
+ pickerRowsNamed(`Status ${String(cap).padStart(3, '0')}`)[0]?.dispatchEvent(
+ new MouseEvent('click', { bubbles: true })
+ )
+ })
+
+ const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter
+ expect(bounded.stateIds).toHaveLength(cap)
+ rerender(bounded)
+
+ expect(document.body.textContent).toContain(
+ `Filtering the most this can carry: ${cap} team statuses`
+ )
+ openSectionNamed('Back')
+ expect(document.body.textContent).toContain(`${cap} selected · partial`)
+ })
+
+ it('reports the team labels left out once a picked label exceeds the id cap', () => {
+ const overCap = LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS + 20
+ metadataMocks.useTeamsLabels.mockImplementation(() => ({
+ data: sameNamedMetadata('bug', overCap),
+ loading: false,
+ error: null
+ }))
+
+ const { rerender, onChange } = renderDropdowns(emptyFilter)
+ openSectionNamed('Labels')
+ act(() => {
+ pickerRowsNamed('bug')[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ const bounded = onChange.mock.calls[0]?.[0] as LinearIssueAttributeFilter
+ expect(bounded.labelIds).toHaveLength(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS)
+ rerender(bounded)
+
+ expect(document.body.textContent).toContain(
+ `Filtering ${LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS} of ${overCap} team labels`
+ )
+ })
+})
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx
index cde4545e5c3..d8b69a9b3a1 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx
+++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx
@@ -4,10 +4,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
import { ListFilter, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useTeamsLabels, useTeamsMembers, useTeamsStates } from '@/hooks/useIssueMetadata'
import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client'
import { translate } from '@/i18n/i18n'
import {
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS,
boundLinearIssueAttributeFilter,
canonicalizeLinearIssueAttributeFilter,
emptyLinearIssueAttributeFilter,
@@ -15,14 +18,17 @@ import {
} from '../../../shared/linear/issue-attribute-filter'
import type { LinearTeam } from '../../../shared/linear/workspace-types'
import {
- LinearIssueFilterSectionDetail,
- LinearIssueFilterSectionMenu,
clearLinearIssueAttributeFacet,
countLinearIssueAttributeFilters,
- linearIssueAttributeFilterPillLabels,
+ linearIssueAttributeFilterPillLabels
+} from './linear-issue-attribute-filter-pills'
+import {
+ LinearIssueFilterSectionDetail,
+ LinearIssueFilterSectionMenu,
type LinearIssueFilterSectionKey
} from './linear-issue-attribute-filter-sections'
import {
+ capLinearMetadataIdsAcrossGroups,
groupLinearMetadataByName,
resolveLinearIssueAttributeFilterTeamIds
} from './linear-issue-attribute-filter-team-ids'
@@ -44,16 +50,40 @@ type Props = {
function ActivePill({
label,
value,
+ partial,
onClear
}: {
label: string
value: string
+ partial: boolean
onClear: () => void
}): React.JSX.Element {
return (
{label}:
{value}
+ {partial ? (
+
+
+ {/* Why: a bare title attribute reaches neither keyboard nor screen reader. */}
+
+ {translate(
+ 'auto.components.linear-issue-attribute-filter-dropdowns.partialCoverage',
+ 'partial'
+ )}
+
+
+
+ {translate(
+ 'auto.components.linear-issue-attribute-filter-dropdowns.partialCoverageTitle',
+ 'Some teams may be left out of this filter. Open Filters for details.'
+ )}
+
+
+ ) : null}
{
+ onChange(
+ boundLinearIssueAttributeFilter(
+ canonicalizeLinearIssueAttributeFilter({
+ ...next,
+ stateIds: capLinearMetadataIdsAcrossGroups(
+ statusOptions,
+ next.stateIds,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
+ ),
+ labelIds: capLinearMetadataIdsAcrossGroups(
+ labelOptions,
+ next.labelIds,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
+ )
+ })
+ )
+ )
+ }
+
const teamRequiredMessage = !primaryTeam
? translate(
'auto.components.linear-issue-attribute-filter-dropdowns.teamRequired',
@@ -283,13 +338,7 @@ export default function LinearIssueAttributeFilterDropdowns({
- onChange(
- boundLinearIssueAttributeFilter(canonicalizeLinearIssueAttributeFilter(next))
- )
- }
+ onChange={applyPickedFilter}
statusOptions={statusOptions}
assigneeOptions={assigneeOptions}
labelOptions={labelOptions}
@@ -305,8 +354,8 @@ export default function LinearIssueAttributeFilterDropdowns({
) : (
)}
@@ -334,6 +383,7 @@ export default function LinearIssueAttributeFilterDropdowns({
key={pill.key}
label={pill.label}
value={pill.value}
+ partial={pill.partial}
onClear={() =>
onChange(
canonicalizeLinearIssueAttributeFilter(
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-pills.ts b/src/renderer/src/components/linear-issue-attribute-filter-pills.ts
new file mode 100644
index 00000000000..bbe01da0f35
--- /dev/null
+++ b/src/renderer/src/components/linear-issue-attribute-filter-pills.ts
@@ -0,0 +1,126 @@
+// Why: the pills are the only place an applied Linear facet filter is visible once the
+// popover closes, so they carry the same coverage truth the picker shows inline.
+import { translate } from '@/i18n/i18n'
+import {
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS,
+ canonicalizeLinearIssueAttributeFilter,
+ type LinearIssueAttributeFilter
+} from '../../../shared/linear/issue-attribute-filter'
+import { getLinearPriorityLabel } from './task-page-localized-options'
+import { isLinearMetadataGroupSelectionPartial } from './linear-issue-attribute-filter-team-ids'
+import type {
+ LinearIssueFilterGroupedOption,
+ LinearIssueFilterSectionKey
+} from './linear-issue-attribute-filter-sections'
+
+/** Same-named ids from different teams are one selection to the user. */
+function distinctFacetNames(ids: readonly string[], namesById: Map): string[] {
+ return [...new Set(ids.map((id) => namesById.get(id) ?? id))]
+}
+
+export function countLinearIssueAttributeFilters(value: LinearIssueAttributeFilter): number {
+ const canonical = canonicalizeLinearIssueAttributeFilter(value)
+ return (
+ (canonical.stateIds.length > 0 ? 1 : 0) +
+ (canonical.priorities.length > 0 ? 1 : 0) +
+ (canonical.assignee ? 1 : 0) +
+ (canonical.labelIds.length > 0 ? 1 : 0)
+ )
+}
+
+export function clearLinearIssueAttributeFacet(
+ value: LinearIssueAttributeFilter,
+ facet: LinearIssueFilterSectionKey
+): LinearIssueAttributeFilter {
+ switch (facet) {
+ case 'status':
+ return { ...value, stateIds: [] }
+ case 'priority':
+ return { ...value, priorities: [] }
+ case 'assignee':
+ return { ...value, assignee: null }
+ case 'labels':
+ return { ...value, labelIds: [] }
+ }
+}
+
+/** A removable filter pill; `partial` marks a facet the transport id cap trimmed (#16879). */
+export type LinearIssueFilterPill = {
+ key: LinearIssueFilterSectionKey
+ label: string
+ value: string
+ partial: boolean
+}
+
+export function linearIssueAttributeFilterPillLabels(options: {
+ value: LinearIssueAttributeFilter
+ stateNamesById: Map
+ memberNamesById: Map
+ labelNamesById: Map
+ statusOptions: readonly LinearIssueFilterGroupedOption[]
+ labelOptions: readonly LinearIssueFilterGroupedOption[]
+}): LinearIssueFilterPill[] {
+ const canonical = canonicalizeLinearIssueAttributeFilter(options.value)
+ const pills: LinearIssueFilterPill[] = []
+ if (canonical.stateIds.length > 0) {
+ pills.push({
+ key: 'status',
+ label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
+ value: distinctFacetNames(canonical.stateIds, options.stateNamesById).join(', '),
+ partial: isLinearMetadataGroupSelectionPartial(
+ options.statusOptions,
+ canonical.stateIds,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
+ )
+ })
+ }
+ if (canonical.priorities.length > 0) {
+ pills.push({
+ key: 'priority',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.priority',
+ 'Priority'
+ ),
+ value: canonical.priorities.map((p) => getLinearPriorityLabel(p)).join(', '),
+ partial: false
+ })
+ }
+ if (canonical.assignee?.kind === 'unassigned') {
+ pills.push({
+ key: 'assignee',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.assignee',
+ 'Assignee'
+ ),
+ value: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.unassigned',
+ 'Unassigned'
+ ),
+ partial: false
+ })
+ } else if (canonical.assignee?.kind === 'user') {
+ pills.push({
+ key: 'assignee',
+ label: translate(
+ 'auto.components.linear-issue-attribute-filter-sections.assignee',
+ 'Assignee'
+ ),
+ value: options.memberNamesById.get(canonical.assignee.id) ?? canonical.assignee.id,
+ partial: false
+ })
+ }
+ if (canonical.labelIds.length > 0) {
+ pills.push({
+ key: 'labels',
+ label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
+ value: distinctFacetNames(canonical.labelIds, options.labelNamesById).join(', '),
+ partial: isLinearMetadataGroupSelectionPartial(
+ options.labelOptions,
+ canonical.labelIds,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
+ )
+ })
+ }
+ return pills
+}
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx b/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx
index ff616aea603..09b3d1b85c9 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx
+++ b/src/renderer/src/components/linear-issue-attribute-filter-sections.tsx
@@ -8,12 +8,15 @@ import {
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
- canonicalizeLinearIssueAttributeFilter,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS,
type LinearIssueAttributeFilter
} from '../../../shared/linear/issue-attribute-filter'
import { getLinearPriorityLabel } from './task-page-localized-options'
+import { LinearFacetCoverageNotice } from './linear-issue-attribute-filter-coverage-notice'
import {
expandLinearMetadataGroupKeys,
+ isLinearMetadataGroupSelectionPartial,
selectedLinearMetadataGroupKeys
} from './linear-issue-attribute-filter-team-ids'
@@ -22,94 +25,6 @@ export type LinearIssueFilterSectionKey = 'status' | 'priority' | 'assignee' | '
/** Picker row backed by every same-named id across the selected teams (#16785). */
export type LinearIssueFilterGroupedOption = PickerOption & { ids: string[] }
-/** Same-named ids from different teams are one selection to the user. */
-function distinctFacetNames(ids: readonly string[], namesById: Map): string[] {
- return [...new Set(ids.map((id) => namesById.get(id) ?? id))]
-}
-
-export function countLinearIssueAttributeFilters(value: LinearIssueAttributeFilter): number {
- const canonical = canonicalizeLinearIssueAttributeFilter(value)
- return (
- (canonical.stateIds.length > 0 ? 1 : 0) +
- (canonical.priorities.length > 0 ? 1 : 0) +
- (canonical.assignee ? 1 : 0) +
- (canonical.labelIds.length > 0 ? 1 : 0)
- )
-}
-
-export function clearLinearIssueAttributeFacet(
- value: LinearIssueAttributeFilter,
- facet: LinearIssueFilterSectionKey
-): LinearIssueAttributeFilter {
- switch (facet) {
- case 'status':
- return { ...value, stateIds: [] }
- case 'priority':
- return { ...value, priorities: [] }
- case 'assignee':
- return { ...value, assignee: null }
- case 'labels':
- return { ...value, labelIds: [] }
- }
-}
-
-export function linearIssueAttributeFilterPillLabels(options: {
- value: LinearIssueAttributeFilter
- stateNamesById: Map
- memberNamesById: Map
- labelNamesById: Map
-}): { key: LinearIssueFilterSectionKey; label: string; value: string }[] {
- const canonical = canonicalizeLinearIssueAttributeFilter(options.value)
- const pills: { key: LinearIssueFilterSectionKey; label: string; value: string }[] = []
- if (canonical.stateIds.length > 0) {
- pills.push({
- key: 'status',
- label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
- value: distinctFacetNames(canonical.stateIds, options.stateNamesById).join(', ')
- })
- }
- if (canonical.priorities.length > 0) {
- pills.push({
- key: 'priority',
- label: translate(
- 'auto.components.linear-issue-attribute-filter-sections.priority',
- 'Priority'
- ),
- value: canonical.priorities.map((p) => getLinearPriorityLabel(p)).join(', ')
- })
- }
- if (canonical.assignee?.kind === 'unassigned') {
- pills.push({
- key: 'assignee',
- label: translate(
- 'auto.components.linear-issue-attribute-filter-sections.assignee',
- 'Assignee'
- ),
- value: translate(
- 'auto.components.linear-issue-attribute-filter-sections.unassigned',
- 'Unassigned'
- )
- })
- } else if (canonical.assignee?.kind === 'user') {
- pills.push({
- key: 'assignee',
- label: translate(
- 'auto.components.linear-issue-attribute-filter-sections.assignee',
- 'Assignee'
- ),
- value: options.memberNamesById.get(canonical.assignee.id) ?? canonical.assignee.id
- })
- }
- if (canonical.labelIds.length > 0) {
- pills.push({
- key: 'labels',
- label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
- value: distinctFacetNames(canonical.labelIds, options.labelNamesById).join(', ')
- })
- }
- return pills
-}
-
function priorityOptions(): PickerOption[] {
return [0, 1, 2, 3, 4].map((priority) => ({
key: String(priority),
@@ -117,31 +32,50 @@ function priorityOptions(): PickerOption[] {
}))
}
+/** "{{count}} selected", flagged when the transport id cap left teams out (#16879). */
+function facetSummary(
+ options: readonly LinearIssueFilterGroupedOption[],
+ selectedIds: readonly string[],
+ max: number
+): string {
+ const count = selectedLinearMetadataGroupKeys(options, selectedIds).length
+ if (count === 0) {
+ return ''
+ }
+ const summary = translate(
+ 'auto.components.linear-issue-attribute-filter-sections.countSelected',
+ '{{count}} selected',
+ { count }
+ )
+ return isLinearMetadataGroupSelectionPartial(options, selectedIds, max)
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-sections.partialCoverageSuffix',
+ '{{value0}} · partial',
+ { value0: summary }
+ )
+ : summary
+}
+
export function LinearIssueFilterSectionMenu({
value,
- stateNamesById,
- labelNamesById,
+ statusOptions,
+ labelOptions,
onOpenSection
}: {
value: LinearIssueAttributeFilter
- stateNamesById: Map
- labelNamesById: Map
+ statusOptions: LinearIssueFilterGroupedOption[]
+ labelOptions: LinearIssueFilterGroupedOption[]
onOpenSection: (section: LinearIssueFilterSectionKey) => void
}): React.JSX.Element {
- const selectedStatusCount = distinctFacetNames(value.stateIds, stateNamesById).length
- const selectedLabelCount = distinctFacetNames(value.labelIds, labelNamesById).length
const sections: { key: LinearIssueFilterSectionKey; label: string; summary: string }[] = [
{
key: 'status',
label: translate('auto.components.linear-issue-attribute-filter-sections.status', 'Status'),
- summary:
- selectedStatusCount > 0
- ? translate(
- 'auto.components.linear-issue-attribute-filter-sections.countSelected',
- '{{count}} selected',
- { count: selectedStatusCount }
- )
- : ''
+ summary: facetSummary(
+ statusOptions,
+ value.stateIds,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
+ )
},
{
key: 'priority',
@@ -176,14 +110,11 @@ export function LinearIssueFilterSectionMenu({
{
key: 'labels',
label: translate('auto.components.linear-issue-attribute-filter-sections.labels', 'Labels'),
- summary:
- selectedLabelCount > 0
- ? translate(
- 'auto.components.linear-issue-attribute-filter-sections.countSelected',
- '{{count}} selected',
- { count: selectedLabelCount }
- )
- : ''
+ summary: facetSummary(
+ labelOptions,
+ value.labelIds,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
+ )
}
]
@@ -300,42 +231,43 @@ export function LinearIssueFilterSectionDetail({
)
}
- if (section === 'status') {
+ // Status and labels are the same grouped, cap-bounded picker over a different facet.
+ if (section === 'status' || section === 'labels') {
+ const isStatus = section === 'status'
+ const options = isStatus ? statusOptions : labelOptions
+ const selectedIds = isStatus ? value.stateIds : value.labelIds
return (
- onChange({ ...value, stateIds: expandLinearMetadataGroupKeys(statusOptions, keys) })
+ options={options}
+ selected={selectedLinearMetadataGroupKeys(options, selectedIds)}
+ loading={isStatus ? statusLoading : labelLoading}
+ error={isStatus ? statusError : labelError}
+ searchPlaceholder={
+ isStatus
+ ? translate(
+ 'auto.components.linear-issue-attribute-filter-sections.searchStatus',
+ 'Filter status…'
+ )
+ : translate(
+ 'auto.components.linear-issue-attribute-filter-sections.searchLabels',
+ 'Filter labels…'
+ )
}
+ onChange={(keys) => {
+ const ids = expandLinearMetadataGroupKeys(options, keys)
+ onChange(isStatus ? { ...value, stateIds: ids } : { ...value, labelIds: ids })
+ }}
/>
-
- )
- }
-
- if (section === 'labels') {
- return (
-
-
-
- onChange({ ...value, labelIds: expandLinearMetadataGroupKeys(labelOptions, keys) })
+
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts
index f8fcbbf9623..d94216eb0b7 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts
+++ b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { LinearTeam } from '../../../shared/linear/workspace-types'
import {
+ capLinearMetadataIdsAcrossGroups,
expandLinearMetadataGroupKeys,
groupLinearMetadataByName,
+ isLinearMetadataGroupSelectionPartial,
+ linearMetadataGroupCoverage,
resolveLinearIssueAttributeFilterTeamIds,
selectedLinearMetadataGroupKeys,
unionLinearMetadataById
@@ -131,3 +134,99 @@ describe('groupLinearMetadataByName', () => {
expect(expandLinearMetadataGroupKeys(groups, ['other-team-todo'])).toEqual(['other-team-todo'])
})
})
+
+describe('capLinearMetadataIdsAcrossGroups', () => {
+ const groups = [
+ { key: 'alpha', ids: ['a-1', 'a-2', 'a-3'] },
+ { key: 'zeta', ids: ['z-1'] }
+ ]
+
+ it('leaves a selection already within the cap untouched', () => {
+ expect(capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2'], 3)).toEqual(['a-1', 'a-2'])
+ })
+
+ // Why: a plain slice of the sorted id list drops the whole trailing group, which then
+ // renders unchecked and disappears from the coverage count (#16879).
+ it('keeps an id from every picked group instead of slicing the last one away', () => {
+ expect(capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2', 'a-3', 'z-1'], 3)).toEqual([
+ 'a-1',
+ 'z-1',
+ 'a-2'
+ ])
+ })
+
+ it('treats an id no group covers as its own group', () => {
+ expect(capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2', 'a-3', 'other'], 2)).toEqual([
+ 'a-1',
+ 'other'
+ ])
+ })
+})
+
+describe('capLinearMetadataIdsAcrossGroups over-subscribed rows', () => {
+ const singleIdGroups = (count: number): { key: string; ids: string[] }[] =>
+ Array.from({ length: count }, (_unused, index) => ({
+ key: `s${index}`,
+ ids: [`s${index}`]
+ }))
+
+ it('leaves a selection sitting exactly on the cap untouched', () => {
+ const groups = singleIdGroups(4)
+ const ids = groups.flatMap((group) => group.ids)
+ expect(capLinearMetadataIdsAcrossGroups(groups, ids, 4)).toEqual(ids)
+ // Why: on the cap, full coverage is indistinguishable from a starved row, so coverage
+ // warns rather than claim what it cannot prove — below the cap it stays quiet (#17342).
+ expect(isLinearMetadataGroupSelectionPartial(groups, ids, 4)).toBe(true)
+ expect(isLinearMetadataGroupSelectionPartial(groups, ids, 5)).toBe(false)
+ })
+
+ it('keeps one id per row when the selection is one id over the cap', () => {
+ const groups = [
+ { key: 'alpha', ids: ['a-1', 'a-2'] },
+ { key: 'beta', ids: ['b-1'] },
+ { key: 'gamma', ids: ['c-1'] }
+ ]
+ const capped = capLinearMetadataIdsAcrossGroups(groups, ['a-1', 'a-2', 'b-1', 'c-1'], 3)
+ expect(new Set(capped)).toEqual(new Set(['a-1', 'b-1', 'c-1']))
+ })
+
+ // Why: MultiSelectList.toggle appends the clicked key last, so the starved row was always
+ // the row the user just clicked — and coverage still reported a full 100 of 100.
+ it('never claims full coverage when more rows are picked than the cap can hold', () => {
+ const groups = singleIdGroups(101)
+ const ids = groups.flatMap((group) => group.ids)
+ const capped = capLinearMetadataIdsAcrossGroups(groups, ids, 100)
+ expect(capped).toHaveLength(100)
+ expect(linearMetadataGroupCoverage(groups, capped, 100).atLimit).toBe(true)
+ expect(isLinearMetadataGroupSelectionPartial(groups, capped, 100)).toBe(true)
+ })
+
+ it('never starves a single-id row to widen a row that has many ids', () => {
+ const groups = [
+ { key: 'wide', ids: Array.from({ length: 10 }, (_unused, index) => `w-${index}`) },
+ { key: 'x', ids: ['x-1'] },
+ { key: 'y', ids: ['y-1'] },
+ { key: 'z', ids: ['z-1'] }
+ ]
+ const ids = groups.flatMap((group) => group.ids)
+ const capped = capLinearMetadataIdsAcrossGroups(groups, ids, 5)
+ expect(capped).toHaveLength(5)
+ expect(capped).toContain('x-1')
+ expect(capped).toContain('y-1')
+ expect(capped).toContain('z-1')
+ })
+
+ // Why: the picker hands us click order, so the cap has to sort by metadata order instead.
+ it('caps the same visible selection to the same ids whatever the click order', () => {
+ const groups = [
+ { key: 'alpha', ids: ['a-1', 'a-2', 'a-3'] },
+ { key: 'beta', ids: ['b-1', 'b-2'] },
+ { key: 'gamma', ids: ['c-1'] }
+ ]
+ const alphaFirst = ['a-1', 'a-2', 'a-3', 'b-1', 'b-2', 'c-1']
+ const gammaFirst = ['c-1', 'b-1', 'b-2', 'a-1', 'a-2', 'a-3']
+ expect(capLinearMetadataIdsAcrossGroups(groups, gammaFirst, 4)).toEqual(
+ capLinearMetadataIdsAcrossGroups(groups, alphaFirst, 4)
+ )
+ })
+})
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts
index 08fb9309b45..df8cb3ce016 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts
+++ b/src/renderer/src/components/linear-issue-attribute-filter-team-ids.ts
@@ -81,13 +81,97 @@ export function selectedLinearMetadataGroupKeys(
groups: readonly { key: string; ids: readonly string[] }[],
selectedIds: readonly string[]
): string[] {
+ const keyById = linearMetadataKeyById(groups)
+ return [...new Set(selectedIds.map((id) => keyById.get(id) ?? id))]
+}
+
+function linearMetadataKeyById(
+ groups: readonly { key: string; ids: readonly string[] }[]
+): Map {
const keyById = new Map()
for (const group of groups) {
for (const id of group.ids) {
keyById.set(id, group.key)
}
}
- return [...new Set(selectedIds.map((id) => keyById.get(id) ?? id))]
+ return keyById
+}
+
+/** Ids the picked rows stand for, against the ids the transport cap actually kept (#16879). */
+export function linearMetadataGroupCoverage(
+ groups: readonly { key: string; ids: readonly string[] }[],
+ selectedIds: readonly string[],
+ max: number
+): { applied: number; intended: number; atLimit: boolean } {
+ const keys = selectedLinearMetadataGroupKeys(groups, selectedIds)
+ return {
+ applied: selectedIds.length,
+ intended: expandLinearMetadataGroupKeys(groups, keys).length,
+ // Why: a row the cap could not fit leaves no trace in the ids, so once the budget is
+ // spent the honest claim is the exhausted budget itself — never full coverage (#17342).
+ atLimit: selectedIds.length >= max
+ }
+}
+
+/** True when the filter cannot be shown to cover every team id the picked rows stand for. */
+export function isLinearMetadataGroupSelectionPartial(
+ groups: readonly { key: string; ids: readonly string[] }[],
+ selectedIds: readonly string[],
+ max: number
+): boolean {
+ const { applied, intended, atLimit } = linearMetadataGroupCoverage(groups, selectedIds, max)
+ return intended > applied || atLimit
+}
+
+/**
+ * Trim an expanded selection to `max` ids by taking turns across the picked groups.
+ * A plain slice of the canonical (sorted) id list can drop every id of one picked row,
+ * which then renders unchecked with no explanation and vanishes from the coverage count.
+ * More picked rows than `max` cannot all be represented; `linearMetadataGroupCoverage`
+ * reports that shortfall so the starved row is never passed off as full coverage.
+ */
+export function capLinearMetadataIdsAcrossGroups(
+ groups: readonly { key: string; ids: readonly string[] }[],
+ ids: readonly string[],
+ max: number
+): string[] {
+ if (ids.length <= max) {
+ return [...ids]
+ }
+ const selected = new Set(ids)
+ // Why: the picker hands us click order, so bucket by metadata order instead — the same
+ // visible selection must always cap to the same ids (#17342).
+ const lists = groups
+ .map((group) => group.ids.filter((id) => selected.has(id)))
+ .filter((list) => list.length > 0)
+ const grouped = new Set(lists.flat())
+ // An id no loaded group covers is its own row; sorted so its slot is stable too (R12).
+ lists.push(
+ ...[...new Set(ids)]
+ .filter((id) => !grouped.has(id))
+ .sort()
+ .map((id) => [id])
+ )
+ const capped: string[] = []
+ // Round 0 gives every row one id before any row gets a second.
+ for (let round = 0; capped.length < max; round += 1) {
+ let advanced = false
+ for (const list of lists) {
+ const id = list[round]
+ if (id === undefined) {
+ continue
+ }
+ advanced = true
+ capped.push(id)
+ if (capped.length >= max) {
+ break
+ }
+ }
+ if (!advanced) {
+ break
+ }
+ }
+ return capped
}
/** Every id behind the picked group keys; an unknown key is itself an id. */
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 0519b661cce..31b048c7d1c 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -16185,7 +16185,15 @@
"allWorkspacesTitle": "Select one workspace",
"allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.",
"optionsFromTeam": "Options from {{team}}",
- "clearAll": "Clear all filters"
+ "clearAll": "Clear all filters",
+ "partialCoverage": "partial",
+ "partialCoverageTitle": "Some teams may be left out of this filter. Open Filters for details."
+ },
+ "linear-issue-attribute-filter-coverage-notice": {
+ "statusPartialTeamCoverage": "Filtering {{value0}} of {{value1}} team statuses — issues from the remaining teams are not included.",
+ "labelsPartialTeamCoverage": "Filtering {{value0}} of {{value1}} team labels — issues from the remaining teams are not included.",
+ "statusAtIdLimit": "Filtering the most this can carry: {{value0}} team statuses. Rows picked past that are left out.",
+ "labelsAtIdLimit": "Filtering the most this can carry: {{value0}} team labels. Rows picked past that are left out."
},
"linear-issue-attribute-filter-sections": {
"status": "Status",
@@ -16194,6 +16202,7 @@
"unassigned": "Unassigned",
"labels": "Labels",
"countSelected": "{{count}} selected",
+ "partialCoverageSuffix": "{{value0}} · partial",
"selected": "selected",
"searchPriority": "Filter priority…",
"searchStatus": "Filter status…",