Persist the Linear issue list view and per-workspace filters (#12710)

* Persist the Linear issue list view and per-workspace filters

Layout, grouping, ordering, columns, and attribute filters survive a restart.
Facet ids are workspace-scoped, so filters are kept per Linear workspace and the
active filter is *derived* from the selected workspace rather than reset by an
effect on switch — no ordering race can apply workspace A's facets to B, and an
unresolved or cross-workspace selection reads as unfiltered without erasing
anything.

A single shared catalog backs the renderer state, `TaskResumeState`, and the
strict `ui.set` schema, so a new view option cannot leave paired web/mobile/relay
clients rejecting the whole payload. Persisted values are normalized as untrusted
input: a corrupt preference or a single bad workspace entry is dropped without
taking the rest of the resume state with it.

Deriving the filter also removed the guard that used to make three neighbouring
behaviours safe, so they are re-scoped here:

- The primary-team facet reset now fires only on an in-workspace team change.
  A workspace switch also changes the primary team, and clearing there wiped the
  filter that had just been restored for the workspace being switched *to*.
- The list-read force check no longer fires on the session's first read, so a
  restored filter serves warm cache instead of forcing a network round trip
  behind a blocking spinner on every cold start.
- The filter dropdown derives "no single workspace" from `workspaceId` alone.
  With an unresolved workspace it previously rendered the statically populated
  priority section, whose clicks now have nowhere to be stored.

* Harden Linear view persistence against the failures review surfaced

Five issues, each found by a reviewer and reproduced before fixing:

- The filter dropdown's prune effect only ran when the user opened the popover,
  because the filter was always empty at startup. Restoration makes it run on
  mount, where `availableTeams` may still be the issue-scraped fallback rather
  than the real fetch. Metadata complete for a *partial* team set passes every
  R12 guard, so it pruned facets belonging to teams it simply hadn't seen — and
  the write persisted, deleting them permanently. Gated on `teamsSettled`.

- `canonicalize` dedupes but enforces none of the transport bounds; only the
  throwing parser does. So `serialize` could emit a 101-label filter that the
  strict `ui.set` schema rejects, which drops the WHOLE taskResumeState — github,
  jira and linear query included — on every subsequent write, since the renderer
  resends the merged object each time. Added `boundLinearIssueAttributeFilter`
  and a round-trip test built from serializer output rather than a literal, which
  is the only kind that can catch renderer/schema drift.

- `linearIssueView` now carries `.catch(undefined)`: value tolerance stops at the
  top level, so any future instance of the above is a cosmetic reset of the view
  instead of silent loss of every other resume field.

- A workspace switch forced an uncached list read in both directions. The switch
  is a later observation, so the null-baseline fix didn't cover it; the cache is
  already workspace-keyed, making the force pure cost.

- Recency for the 20-workspace cap came from object key order, which is wrong
  twice: re-filtering an existing workspace left it at the head (first evicted,
  though just used), and an array-index-like key enumerates first regardless of
  insertion, so a write could evict the very entry it added. Recency is now an
  explicit ordered key list.

Also adds the nested parity assertion — the top-level one compares only
TaskResumeState's own keys, so a field added to LinearIssueViewResumeState stayed
invisible to it, which is exactly what `.strict()` rejects.

The wiring test was blind: deleting the hydration guard outright left all four
assertions green. The gate is now `shouldPersistLinearIssueView`, unit-tested
directly, and the file is renamed to the repo's `*-boundary.test.ts` convention
with an assertion that fails on that mutation.

* Log discarded Linear views and fix empty-filter serialization

- Schema now logs when linearIssueView is discarded, making validation failures visible
- Fixed serialization: filters that become empty after bounding are now omitted
- Added AssertNoExtraKeys type check for bidirectional schema/type parity
- Refactored view option catalogs to use canonical constants, preventing UI/schema drift

* Remove workspace persistence limits and LRU eviction

Stop capping persisted Linear workspace filters at 20 and evicting
least-recently-used workspaces. Simplify persistence to store all
workspace filters, gate persistence only on resume state application,
and remove tests that pinned implementation details. Users can now
persist filters for all their workspaces without arbitrary limits.

* add test for linear persistence

* Improve Linear filter test clarity and fix e2e overlay dismissal for CI

- Convert parameterized filter-pruning test to sequential assertions
- Fix dismissOverlayChrome to toggle overlay triggers instead of
  force-clicking inert page elements in headless CI

* Prevent TaskPage from stealing Escape from Radix menus

- Add check to detect open Radix dropdown menus and popovers; return
  early from Escape handler to respect their capture-phase ownership
- Update overlay dismissal in e2e tests to use keyboard.press('Escape'),
  now that TaskPage no longer interferes

* The capture-phase Escape guard in TaskPage bailed out for open dropdown menus and popovers, but an open Radix Select matches none of those selectors: the shared SelectContent wrapper (src/renderer/src/components/ui/select.tsx:60) renders data-slot="select-content" and Radix gives its content role="listbox", not role="menu". So with a select open, the window-level capture handler ran first, called preventDefault() and closeTaskPage() — closing the whole task page instead of just the select. Added [data-slot="select-content"] to the guard, as suggested. I did not add [role="listbox"]; the reviewer explicitly notes it's too broad, and the data-slot selector covers every select rendered through the shared wrapper.

---------

Co-authored-by: m4air <m4air@MacBook-Air.localdomain>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
OrcaWin 2026-08-07 20:41:59 -07:00 committed by GitHub
parent 4b5157b147
commit ce20a109da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1813 additions and 111 deletions

View File

@ -0,0 +1,187 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultUIState } from '../../../../shared/constants'
import {
LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH,
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS
} from '../../../../shared/linear-issue-attribute-filter'
import {
defaultLinearIssueViewResumeState,
serializeLinearIssueViewResumeState
} from '../../../../shared/linear-issue-view-resume-state'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { CLIENT_UI_METHODS } from './client-ui'
function makeRequest(params: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method: 'ui.set', params }
}
function makeDispatcher(): { dispatcher: RpcDispatcher; updateUIState: ReturnType<typeof vi.fn> } {
const updateUIState = vi.fn(() => getDefaultUIState())
const runtime = {
getRuntimeId: () => 'test-runtime',
updateUIState
} as unknown as OrcaRuntimeService
return { dispatcher: new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }), updateUIState }
}
const VALID_VIEW = {
viewMode: 'list',
groupBy: 'none',
orderBy: 'priority',
displayProperties: [],
teamPropertyTouched: false,
filtersByWorkspaceId: {}
}
describe('ui.set Linear issue view resume state', () => {
// Why: the schema is strict, so a field the renderer persists but the schema
// omits drops the whole taskResumeState for paired web/mobile/relay clients.
it.each([
[
'a fully populated view',
{
viewMode: 'board',
groupBy: 'assignee',
orderBy: 'updated',
displayProperties: ['state', 'labels'],
teamPropertyTouched: true,
filtersByWorkspaceId: {
'workspace-1': {
stateIds: ['state-1'],
priorities: [2],
assignee: { kind: 'user', id: 'user-1' },
labelIds: ['label-1']
},
'workspace-2': {
stateIds: [],
priorities: [],
assignee: { kind: 'unassigned' },
labelIds: ['label-2']
}
}
}
],
// Why: an empty display-property array means every column is hidden, not "unset".
['every column hidden and no filters', VALID_VIEW]
])('accepts %s', async (_label, linearIssueView) => {
const { dispatcher, updateUIState } = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest({ taskResumeState: { linearIssueView } })
)
expect(response).toMatchObject({ ok: true })
expect(updateUIState).toHaveBeenCalledWith({ taskResumeState: { linearIssueView } })
})
it.each([
['an unlisted key', { ...VALID_VIEW, linearIssueFilterWorkspaceId: 'workspace-1' }],
['an unknown view mode', { ...VALID_VIEW, viewMode: 'grid' }],
['an unknown display property', { ...VALID_VIEW, displayProperties: ['state', 'bogus'] }],
['a missing required field', { viewMode: 'board', groupBy: 'none' }],
['a non-object filter', { ...VALID_VIEW, filtersByWorkspaceId: { 'workspace-1': 'all' } }],
[
'an out-of-range priority',
{
...VALID_VIEW,
filtersByWorkspaceId: {
'workspace-1': { stateIds: [], priorities: [9], assignee: null, labelIds: [] }
}
}
],
[
'an over-long facet id',
{
...VALID_VIEW,
filtersByWorkspaceId: {
'workspace-1': {
stateIds: ['x'.repeat(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH + 1)],
priorities: [],
assignee: null,
labelIds: []
}
}
}
],
[
'an over-long workspace key',
{
...VALID_VIEW,
filtersByWorkspaceId: {
['w'.repeat(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH + 1)]: {
stateIds: ['state-1'],
priorities: [],
assignee: null,
labelIds: []
}
}
}
]
])('drops only the view when it has %s', async (_label, linearIssueView) => {
const { dispatcher, updateUIState } = makeDispatcher()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const response = await dispatcher.dispatch(
makeRequest({
sidebarWidth: 280,
taskResumeState: { linearQuery: 'label:bug', jiraPreset: 'reported', linearIssueView }
})
)
// Why: value tolerance stops at the top level, so without the schema's own `.catch`
// one bad view value would drop every other resume field too — and keep doing so on
// every subsequent write, since the renderer resends the whole merged object.
expect(response).toMatchObject({ ok: true })
expect(updateUIState).toHaveBeenCalledWith({
sidebarWidth: 280,
taskResumeState: {
linearQuery: 'label:bug',
jiraPreset: 'reported',
linearIssueView: undefined
}
})
// Why: deep equality treats an absent key and an explicit `undefined` as equal, but
// a merging store does not — an explicit `undefined` is what clears the stored view.
const [payload] = updateUIState.mock.calls[0] as [{ taskResumeState: object }]
expect('linearIssueView' in payload.taskResumeState).toBe(true)
// Why: the discard is otherwise invisible; the log is the only trace of the drift.
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('linearIssueView'),
expect.arrayContaining([expect.objectContaining({ code: expect.any(String) })])
)
warn.mockRestore()
})
// Why: hand-written fixtures can't catch renderer/schema drift — only the renderer's
// own output can. An over-limit filter here stops ALL resume state from persisting.
it.each([
[
'a filter at the facet-count limit',
Array.from(
{ length: LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS + 1 },
(_, index) => `label-${String(index).padStart(4, '0')}`
)
],
[
'a filter with an over-long facet id',
['x'.repeat(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH + 1)]
]
])('accepts serializer output for %s', async (_label, labelIds) => {
const { dispatcher, updateUIState } = makeDispatcher()
const linearIssueView = serializeLinearIssueViewResumeState({
...defaultLinearIssueViewResumeState(),
filtersByWorkspaceId: {
'workspace-1': { stateIds: [], priorities: [], assignee: null, labelIds }
}
})
const response = await dispatcher.dispatch(
makeRequest({ taskResumeState: { linearIssueView } })
)
expect(response).toMatchObject({ ok: true })
expect(updateUIState).toHaveBeenCalledWith({ taskResumeState: { linearIssueView } })
})
})

View File

@ -1,6 +1,44 @@
import { z } from 'zod'
import { LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH } from '../../../../shared/linear-issue-attribute-filter'
import {
LINEAR_DISPLAY_PROPERTIES,
LINEAR_GROUP_BY_OPTIONS,
LINEAR_ORDER_BY_OPTIONS,
LINEAR_VIEW_MODES
} from '../../../../shared/linear-issue-view-resume-state'
import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/types'
import type { AssertNoMissingKeys } from './ui-state-schema-parity'
import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema'
import type { AssertNoExtraKeys, AssertNoMissingKeys } from './ui-state-schema-parity'
// Why: built from the shared catalogs and the existing bounded filter schema, so
// adding a view option cannot leave paired clients rejected by a stale enum.
const LinearIssueViewResumeState = z
.object({
viewMode: z.enum(LINEAR_VIEW_MODES),
groupBy: z.enum(LINEAR_GROUP_BY_OPTIONS),
orderBy: z.enum(LINEAR_ORDER_BY_OPTIONS),
displayProperties: z
.array(z.enum(LINEAR_DISPLAY_PROPERTIES))
.max(LINEAR_DISPLAY_PROPERTIES.length),
teamPropertyTouched: z.boolean(),
filtersByWorkspaceId: z.record(
z.string().min(1).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH),
LinearIssueAttributeFilterSchema
)
})
.strict()
// Why: the `.catch` below is a silent data loss — a client whose saved shape this
// schema rejects loses its view on every write with no other trace. Log the issue
// paths (not the values, which carry workspace facet ids) so the drift is findable.
function reportDiscardedLinearIssueView(ctx: z.core.$ZodCatchCtx): undefined {
console.warn(
'[ui.set] discarded an invalid taskResumeState.linearIssueView',
// Raw issues carry no path for a failure on the value itself, only for nested ones.
ctx.issues.map((issue) => ({ path: (issue.path ?? []).join('.'), code: issue.code }))
)
return undefined
}
/** Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. */
export const TaskResumeState = z
@ -21,6 +59,10 @@ export const TaskResumeState = z
})
.strict()
.optional(),
// Why: value tolerance stops at the top level, so without `.catch` one bad view
// value drops the whole taskResumeState — github/jira/linear query included —
// for paired clients on every subsequent write, not just this one.
linearIssueView: LinearIssueViewResumeState.optional().catch(reportDiscardedLinearIssueView),
jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(),
jiraQuery: z.string().optional()
})
@ -31,3 +73,19 @@ const _taskResumeStateParity: AssertNoMissingKeys<
z.infer<typeof TaskResumeState>
> = true
void _taskResumeStateParity
// Why: the top-level assertion only compares TaskResumeState's own keys, so a field
// added one level down stays invisible to it — and `.strict()` rejects exactly that.
const _linearIssueViewParity: AssertNoMissingKeys<
NonNullable<TaskResumeStateType['linearIssueView']>,
z.infer<typeof LinearIssueViewResumeState>
> = true
void _linearIssueViewParity
// Why: `.strict()` only rejects keys the SCHEMA omits. A key the schema gains and the
// shared type lacks passes validation and reaches the renderer unmodelled instead.
const _linearIssueViewExtraKeys: AssertNoExtraKeys<
NonNullable<TaskResumeStateType['linearIssueView']>,
z.infer<typeof LinearIssueViewResumeState>
> = true
void _linearIssueViewExtraKeys

View File

@ -13,6 +13,16 @@ export type AssertNoMissingKeys<TType, TSchema extends Record<string, unknown>>
? true
: { missingFromSchema: Exclude<keyof TType, keyof TSchema> }
/**
* The mirror of `AssertNoMissingKeys`: a key the schema accepts but the shared
* type does not model. `.strict()` cannot catch this one it lets the payload
* through, and the renderer then receives a field it has no type for.
*/
export type AssertNoExtraKeys<TType, TSchema extends Record<string, unknown>> =
Exclude<keyof TSchema, keyof TType> extends never
? true
: { absentFromType: Exclude<keyof TSchema, keyof TType> }
/**
* Key parity alone is blind to VALUE drift: a schema can list `rightSidebarTab`
* yet omit half its union members, which the strict schema then rejects. This

View File

@ -261,8 +261,11 @@ import {
buildLinearIssueListReadArgs,
buildLinearIssueListRequestSignature,
isLinearIssueSearchActive,
shouldClearTeamDerivedFacets,
shouldForceLinearIssueListRead,
teamDerivedFacetsForPrimaryTeamChange
teamDerivedFacetsForPrimaryTeamChange,
type LinearIssueListFilterRead,
type LinearPrimaryTeamObservation
} from '@/components/task-page-linear-issue-request'
import {
resolveLinearIssueEmptyKind,
@ -276,10 +279,19 @@ import {
readLinkedLinearIssuesWithLimit
} from '@/components/task-page-linear-in-orca-issues'
import {
emptyLinearIssueAttributeFilter,
linearIssueAttributeFilterSignature,
type LinearIssueAttributeFilter
} from '../../../shared/linear-issue-attribute-filter'
import {
DEFAULT_LINEAR_GROUP_BY,
DEFAULT_LINEAR_ORDER_BY,
DEFAULT_LINEAR_VIEW_MODE,
LINEAR_DISPLAY_PROPERTIES,
resolveLinearIssueViewResumeState,
selectLinearWorkspaceIssueFilter,
serializeLinearIssueViewResumeState,
setLinearWorkspaceIssueFilter
} from '../../../shared/linear-issue-view-resume-state'
import {
isNewIssueDraftContentful,
resolveNewIssueOpenSeed,
@ -665,15 +677,6 @@ function mergeLinearCollectionResults<T>(
}
}
const DEFAULT_LINEAR_DISPLAY_PROPERTIES: LinearDisplayProperty[] = [
'state',
'priority',
'assignee',
'team',
'labels',
'updated'
]
function getLinearStatusSectionState(section: LinearGroupSection): LinearIssue['state'] | null {
if (!section.key.startsWith('status:')) {
return null
@ -3764,6 +3767,7 @@ export default function TaskPage(): React.JSX.Element {
const taskResumeAppliedRef = useRef(false)
const githubSearchPersistReadyRef = useRef(false)
const linearSearchPersistReadyRef = useRef(false)
const linearViewPersistReadyRef = useRef(false)
const jiraSearchPersistReadyRef = useRef(false)
const [taskResumeApplied, setTaskResumeApplied] = useState(false)
@ -4450,20 +4454,29 @@ export default function TaskPage(): React.JSX.Element {
const [linearError, setLinearError] = useState<string | null>(null)
const [linearSearchInput, setLinearSearchInput] = useState('')
const [appliedLinearSearch, setAppliedLinearSearch] = useState('')
const [linearAttributeFilter, setLinearAttributeFilter] = useState<LinearIssueAttributeFilter>(
() => emptyLinearIssueAttributeFilter()
const [linearIssueFiltersByWorkspaceId, setLinearIssueFiltersByWorkspaceId] = useState<
Record<string, LinearIssueAttributeFilter>
>(() => ({}))
const linearAttributeFilterWorkspaceId =
selectedLinearWorkspaceId && selectedLinearWorkspaceId !== 'all'
? selectedLinearWorkspaceId
: null
const linearAttributeFilter = useMemo(
() =>
selectLinearWorkspaceIssueFilter(
linearIssueFiltersByWorkspaceId,
linearAttributeFilterWorkspaceId
),
[linearAttributeFilterWorkspaceId, linearIssueFiltersByWorkspaceId]
)
const linearAttributeFilterSignatureRef = useRef(
linearIssueAttributeFilterSignature(emptyLinearIssueAttributeFilter())
)
const linearPrimaryTeamIdRef = useRef<string | null>(null)
const previousLinearWorkspaceIdForFiltersRef = useRef<string | null | undefined>(undefined)
const [linearViewMode, setLinearViewMode] = useState<LinearViewMode>('list')
const [linearGroupBy, setLinearGroupBy] = useState<LinearGroupBy>('none')
const [linearOrderBy, setLinearOrderBy] = useState<LinearOrderBy>('priority')
const linearAttributeFilterReadRef = useRef<LinearIssueListFilterRead | null>(null)
const linearPrimaryTeamRef = useRef<LinearPrimaryTeamObservation | null>(null)
const [linearViewMode, setLinearViewMode] = useState<LinearViewMode>(DEFAULT_LINEAR_VIEW_MODE)
const [linearGroupBy, setLinearGroupBy] = useState<LinearGroupBy>(DEFAULT_LINEAR_GROUP_BY)
const [linearOrderBy, setLinearOrderBy] = useState<LinearOrderBy>(DEFAULT_LINEAR_ORDER_BY)
const [linearDisplayProperties, setLinearDisplayProperties] = useState<
ReadonlySet<LinearDisplayProperty>
>(() => new Set(DEFAULT_LINEAR_DISPLAY_PROPERTIES))
>(() => new Set(LINEAR_DISPLAY_PROPERTIES))
const [linearTeamPropertyTouched, setLinearTeamPropertyTouched] = useState(false)
const [linearRefreshNonce, setLinearRefreshNonce] = useState(0)
const [linearProjectSearchInput, setLinearProjectSearchInput] = useState('')
@ -4741,6 +4754,14 @@ export default function TaskPage(): React.JSX.Element {
setLinearSearchInput(linearQuery)
setAppliedLinearSearch(linearQuery)
const linearIssueView = resolveLinearIssueViewResumeState(taskResumeState?.linearIssueView)
setLinearViewMode(linearIssueView.viewMode)
setLinearGroupBy(linearIssueView.groupBy)
setLinearOrderBy(linearIssueView.orderBy)
setLinearDisplayProperties(new Set(linearIssueView.displayProperties))
setLinearTeamPropertyTouched(linearIssueView.teamPropertyTouched)
setLinearIssueFiltersByWorkspaceId(linearIssueView.filtersByWorkspaceId)
const jiraPreset = taskResumeState?.jiraPreset ?? 'assigned'
const jiraQuery = taskResumeState?.jiraQuery ?? ''
setActiveJiraPreset(jiraPreset)
@ -5247,41 +5268,50 @@ export default function TaskPage(): React.JSX.Element {
[linearTeamOptions, linearTeamSelection]
)
const applyLinearAttributeFilter = useCallback((next: LinearIssueAttributeFilter) => {
// Why: batch filter + limit/page reset so the fetch effect never issues an old expanded-limit request for the new filter.
setLinearAttributeFilter(next)
setLinearIssueLimit(LINEAR_ITEM_LIMIT)
setLinearIssuePage(0)
setLinearIssueLoadingTargetPage(null)
}, [])
const applyLinearAttributeFilter = useCallback(
(next: LinearIssueAttributeFilter) => {
if (linearAttributeFilterWorkspaceId) {
setLinearIssueFiltersByWorkspaceId((previous) =>
setLinearWorkspaceIssueFilter(previous, linearAttributeFilterWorkspaceId, next)
)
}
setLinearIssueLimit(LINEAR_ITEM_LIMIT)
setLinearIssuePage(0)
setLinearIssueLoadingTargetPage(null)
},
[linearAttributeFilterWorkspaceId]
)
useEffect(() => {
const workspaceId = selectedLinearWorkspaceId ?? null
const previous = previousLinearWorkspaceIdForFiltersRef.current
previousLinearWorkspaceIdForFiltersRef.current = workspaceId
if (previous === undefined || previous === workspaceId) {
const nextTeamId = availableTeams.length > 0 ? (linearAttributePrimaryTeam?.id ?? null) : null
if (!nextTeamId) {
return
}
applyLinearAttributeFilter(emptyLinearIssueAttributeFilter())
}, [applyLinearAttributeFilter, selectedLinearWorkspaceId])
useEffect(() => {
const nextId = linearAttributePrimaryTeam?.id ?? null
const previousId = linearPrimaryTeamIdRef.current
linearPrimaryTeamIdRef.current = nextId
if (previousId === null || previousId === nextId) {
const previous = linearPrimaryTeamRef.current
const next: LinearPrimaryTeamObservation = {
workspaceId: linearAttributeFilterWorkspaceId,
teamId: nextTeamId
}
linearPrimaryTeamRef.current = next
if (!shouldClearTeamDerivedFacets({ previous, next })) {
return
}
// Why: team-scoped facets; clearing them is a filter change, so reset limit/page via applyLinearAttributeFilter (R6), not a bare set.
const next = teamDerivedFacetsForPrimaryTeamChange(linearAttributeFilter)
const cleared = teamDerivedFacetsForPrimaryTeamChange(linearAttributeFilter)
if (
linearIssueAttributeFilterSignature(linearAttributeFilter) ===
linearIssueAttributeFilterSignature(next)
linearIssueAttributeFilterSignature(cleared)
) {
return
}
applyLinearAttributeFilter(next)
}, [applyLinearAttributeFilter, linearAttributeFilter, linearAttributePrimaryTeam?.id])
applyLinearAttributeFilter(cleared)
}, [
applyLinearAttributeFilter,
availableTeams.length,
linearAttributeFilter,
linearAttributeFilterWorkspaceId,
linearAttributePrimaryTeam?.id
])
const linearSearchActive = isLinearIssueSearchActive(linearSearchInput, appliedLinearSearch)
const showLinearAttributeFilters =
@ -7812,6 +7842,15 @@ export default function TaskPage(): React.JSX.Element {
return
}
// Why: open menus/popovers/selects own Esc; capture-phase leave would steal it from Radix.
if (
document.querySelector(
'[data-slot="dropdown-menu-content"], [data-slot="popover-content"], [data-slot="select-content"], [role="menu"]'
)
) {
return
}
// Why: Esc first blurs a focused input so it doesn't accidentally close the whole page; only closes once focus is outside an input.
if (
target instanceof HTMLInputElement ||
@ -7888,6 +7927,35 @@ export default function TaskPage(): React.JSX.Element {
setTaskResumeState({ linearQuery: appliedLinearSearch.trim() })
}, [appliedLinearSearch, setTaskResumeState, taskResumeApplied])
useEffect(() => {
if (!taskResumeApplied) {
return
}
if (!linearViewPersistReadyRef.current) {
linearViewPersistReadyRef.current = true
return
}
setTaskResumeState({
linearIssueView: serializeLinearIssueViewResumeState({
viewMode: linearViewMode,
groupBy: linearGroupBy,
orderBy: linearOrderBy,
displayProperties: linearDisplayProperties,
teamPropertyTouched: linearTeamPropertyTouched,
filtersByWorkspaceId: linearIssueFiltersByWorkspaceId
})
})
}, [
linearDisplayProperties,
linearGroupBy,
linearIssueFiltersByWorkspaceId,
linearOrderBy,
linearTeamPropertyTouched,
linearViewMode,
setTaskResumeState,
taskResumeApplied
])
useEffect(() => {
setLinearIssueLimit(LINEAR_ITEM_LIMIT)
setLinearIssuePage(0)
@ -7946,12 +8014,15 @@ export default function TaskPage(): React.JSX.Element {
)
}
const nextFilterSignature = linearIssueAttributeFilterSignature(linearAttributeFilter)
const previousFilterSignature = linearAttributeFilterSignatureRef.current
linearAttributeFilterSignatureRef.current = nextFilterSignature
const nextFilterRead: LinearIssueListFilterRead = {
workspaceId: linearAttributeFilterWorkspaceId,
signature: linearIssueAttributeFilterSignature(linearAttributeFilter)
}
const previousFilterRead = linearAttributeFilterReadRef.current
linearAttributeFilterReadRef.current = nextFilterRead
const filterForce = shouldForceLinearIssueListRead({
previousFilterSignature,
nextFilterSignature,
previousFilterRead,
nextFilterRead,
refreshForced: false
})
@ -9454,11 +9525,11 @@ export default function TaskPage(): React.JSX.Element {
<LinearIssueAttributeFilterDropdowns
value={linearAttributeFilter}
onChange={applyLinearAttributeFilter}
workspaceId={selectedLinearWorkspaceId ?? null}
isAllWorkspaces={selectedLinearWorkspaceId === 'all'}
workspaceId={linearAttributeFilterWorkspaceId}
primaryTeam={linearAttributePrimaryTeam}
selectedTeamIds={[...linearTeamSelection]}
availableTeams={linearTeamOptions}
teamsSettled={availableTeams.length > 0}
settings={linearTaskSourceContext ?? settings}
/>
) : null}

View File

@ -96,10 +96,10 @@ describe('LinearIssueAttributeFilterDropdowns', () => {
}}
onChange={() => undefined}
workspaceId="workspace-1"
isAllWorkspaces={false}
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled
/>
)
})
@ -128,10 +128,10 @@ describe('LinearIssueAttributeFilterDropdowns', () => {
value={value}
onChange={() => undefined}
workspaceId="workspace-1"
isAllWorkspaces={false}
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled
/>
)
})
@ -144,4 +144,79 @@ describe('LinearIssueAttributeFilterDropdowns', () => {
expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
})
// Why: filters are stored per workspace, so with no single workspace resolved a
// click would be silently dropped — show the picker hint instead of the sections.
it.each([['all'], [null]])(
'offers the workspace picker hint instead of filter sections for workspaceId %s',
(workspaceId) => {
const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
act(() => {
root.render(
<LinearIssueAttributeFilterDropdowns
value={{ stateIds: [], priorities: [], assignee: null, labelIds: [] }}
onChange={() => undefined}
workspaceId={workspaceId}
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled
/>
)
})
const trigger = container.querySelector('button')
act(() => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(document.body.textContent).toContain('Select one workspace')
expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith([], undefined, null)
}
)
// Why: restored filters render before the team fetch settles, when availableTeams is
// still the issue-scraped subset — pruning there deletes another team's facets for good.
it('prunes unknown facet ids only once teams settle on the mounted component', () => {
const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
const onChange = vi.fn()
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
const renderWith = (teamsSettled: boolean): void => {
act(() => {
root.render(
<LinearIssueAttributeFilterDropdowns
value={{
stateIds: ['state-from-another-team'],
priorities: [],
assignee: null,
labelIds: []
}}
onChange={onChange}
workspaceId="workspace-1"
primaryTeam={team}
selectedTeamIds={[]}
availableTeams={[team]}
teamsSettled={teamsSettled}
/>
)
})
}
renderWith(false)
expect(onChange).not.toHaveBeenCalled()
renderWith(true)
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ stateIds: [], labelIds: [], assignee: null })
)
})
})

View File

@ -26,12 +26,14 @@ import { resolveLinearIssueAttributeFilterTeamIds } from './linear-issue-attribu
type Props = {
value: LinearIssueAttributeFilter
onChange: (next: LinearIssueAttributeFilter) => void
/** `null` or `all` means no single workspace owns the facet ids. */
workspaceId: string | null
isAllWorkspaces: boolean
primaryTeam: LinearTeam | null
/** Selected Linear team ids (All teams / multi-select). Empty → primary fallback. */
selectedTeamIds: readonly string[]
availableTeams: readonly LinearTeam[]
/** False while `availableTeams` is still the issue-scraped fallback, not the real fetch. */
teamsSettled: boolean
settings?: RuntimeLinearSettings
}
@ -68,10 +70,10 @@ export default function LinearIssueAttributeFilterDropdowns({
value,
onChange,
workspaceId,
isAllWorkspaces,
primaryTeam,
selectedTeamIds,
availableTeams,
teamsSettled,
settings
}: Props): React.JSX.Element {
const [popoverOpen, setPopoverOpen] = useState(false)
@ -83,8 +85,12 @@ export default function LinearIssueAttributeFilterDropdowns({
value.labelIds.length > 0 ||
value.assignee?.kind === 'user'
// Why: facet ids belong to one workspace, so an unresolved id is as unusable as `all`
// — both must show the picker hint rather than accept a filter that goes nowhere.
const scopedWorkspaceId = workspaceId && workspaceId !== 'all' ? workspaceId : null
const activeTeamIds = useMemo(() => {
if (!metadataNeeded || isAllWorkspaces) {
if (!metadataNeeded || !scopedWorkspaceId) {
return [] as string[]
}
return resolveLinearIssueAttributeFilterTeamIds({
@ -92,10 +98,9 @@ export default function LinearIssueAttributeFilterDropdowns({
availableTeams,
primaryTeamId: primaryTeam?.id ?? null
})
}, [metadataNeeded, isAllWorkspaces, selectedTeamIds, availableTeams, primaryTeam?.id])
}, [metadataNeeded, scopedWorkspaceId, selectedTeamIds, availableTeams, primaryTeam?.id])
const concreteWorkspaceId =
metadataNeeded && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null
const concreteWorkspaceId = metadataNeeded ? scopedWorkspaceId : null
// Why: multi-team / All teams must union filter options across every selected team (#8739).
const states = useTeamsStates(activeTeamIds, settings, concreteWorkspaceId)
@ -109,6 +114,12 @@ export default function LinearIssueAttributeFilterDropdowns({
if (activeTeamIds.length === 0 || !concreteWorkspaceId) {
return
}
// Why: restored filters make this run at startup, when availableTeams may still be
// the issue-scraped subset. Metadata complete for a partial team set looks valid,
// so pruning there would permanently delete facets from another team (R12).
if (!teamsSettled) {
return
}
if (states.loading || labels.loading || members.loading) {
return
}
@ -141,6 +152,7 @@ export default function LinearIssueAttributeFilterDropdowns({
}, [
activeTeamIds,
concreteWorkspaceId,
teamsSettled,
states.loading,
states.error,
states.data,
@ -234,7 +246,7 @@ export default function LinearIssueAttributeFilterDropdowns({
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-0">
{isAllWorkspaces ? (
{!scopedWorkspaceId ? (
<div className="space-y-2 p-3 text-xs">
<p className="font-medium text-foreground">
{translate(

View File

@ -3,6 +3,7 @@ import {
buildLinearIssueListReadArgs,
buildLinearIssueListRequestSignature,
isLinearIssueSearchActive,
shouldClearTeamDerivedFacets,
shouldForceLinearIssueListRead,
teamDerivedFacetsForPrimaryTeamChange
} from './task-page-linear-issue-request'
@ -49,23 +50,80 @@ describe('task-page-linear-issue-request', () => {
expect(signature).toContain('"stateIds":["s1"]')
})
it('forces a read when the filter signature changes', () => {
it('forces a read when the filter signature changes within one workspace', () => {
expect(
shouldForceLinearIssueListRead({
previousFilterSignature: 'a',
nextFilterSignature: 'b',
previousFilterRead: { workspaceId: 'ws-1', signature: 'a' },
nextFilterRead: { workspaceId: 'ws-1', signature: 'b' },
refreshForced: false
})
).toBe(true)
expect(
shouldForceLinearIssueListRead({
previousFilterSignature: 'a',
nextFilterSignature: 'a',
previousFilterRead: { workspaceId: 'ws-1', signature: 'a' },
nextFilterRead: { workspaceId: 'ws-1', signature: 'a' },
refreshForced: false
})
).toBe(false)
})
it('does not force the first read of a session, so a restored filter serves warm cache', () => {
expect(
shouldForceLinearIssueListRead({
previousFilterRead: null,
nextFilterRead: { workspaceId: 'ws-1', signature: 'restored' },
refreshForced: false
})
).toBe(false)
expect(
shouldForceLinearIssueListRead({
previousFilterRead: null,
nextFilterRead: { workspaceId: 'ws-1', signature: 'restored' },
refreshForced: true
})
).toBe(true)
})
// Why: the list cache is workspace-keyed, so B's warm entry is already correct —
// forcing would mean a round trip behind a blocking spinner on every switch.
it('does not force on a workspace switch, even when each workspace has its own filter', () => {
expect(
shouldForceLinearIssueListRead({
previousFilterRead: { workspaceId: 'ws-1', signature: 'filter-a' },
nextFilterRead: { workspaceId: 'ws-2', signature: 'filter-b' },
refreshForced: false
})
).toBe(false)
})
it('clears team-derived facets only when the primary team changes within one workspace', () => {
expect(
shouldClearTeamDerivedFacets({
previous: { workspaceId: 'ws-1', teamId: 'team-a' },
next: { workspaceId: 'ws-1', teamId: 'team-b' }
})
).toBe(true)
// Why: a workspace switch swaps in that workspace's own persisted filter.
expect(
shouldClearTeamDerivedFacets({
previous: { workspaceId: 'ws-1', teamId: 'team-a' },
next: { workspaceId: 'ws-2', teamId: 'team-b' }
})
).toBe(false)
expect(
shouldClearTeamDerivedFacets({
previous: null,
next: { workspaceId: 'ws-1', teamId: 'team-a' }
})
).toBe(false)
expect(
shouldClearTeamDerivedFacets({
previous: { workspaceId: 'ws-1', teamId: 'team-a' },
next: { workspaceId: 'ws-1', teamId: 'team-a' }
})
).toBe(false)
})
it('clears team-derived facets while preserving priority', () => {
expect(teamDerivedFacetsForPrimaryTeamChange(filter)).toEqual({
stateIds: [],

View File

@ -18,7 +18,6 @@ export function buildLinearIssueListReadArgs(options: {
limit: number
attributeFilter: LinearIssueAttributeFilter
searchActive: boolean
/** Concrete workspace only; `all` must never send workspace-scoped facet ids. */
allowAttributeFilter?: boolean
}): LinearIssueListReadArgs {
const attributeFilter =
@ -54,23 +53,41 @@ export function buildLinearIssueListRequestSignature(options: {
return `${sourceScope}::${workspace}::list::${options.filter ?? 'all'}::${options.limit}::${signature}`
}
export type LinearIssueListFilterRead = { workspaceId: string | null; signature: string }
export function shouldForceLinearIssueListRead(options: {
previousFilterSignature: string
nextFilterSignature: string
previousFilterRead: LinearIssueListFilterRead | null
nextFilterRead: LinearIssueListFilterRead
refreshForced: boolean
}): boolean {
if (options.refreshForced) {
return true
}
// Why: filter signature changes always need a current server read, even when
// returning to a previously cached signature that is still warm.
return options.previousFilterSignature !== options.nextFilterSignature
if (options.previousFilterRead === null) {
return false
}
if (options.previousFilterRead.workspaceId !== options.nextFilterRead.workspaceId) {
return false
}
return options.previousFilterRead.signature !== options.nextFilterRead.signature
}
export type LinearPrimaryTeamObservation = { workspaceId: string | null; teamId: string }
export function shouldClearTeamDerivedFacets(options: {
previous: LinearPrimaryTeamObservation | null
next: LinearPrimaryTeamObservation
}): boolean {
const { previous, next } = options
if (!previous) {
return false
}
return previous.workspaceId === next.workspaceId && previous.teamId !== next.teamId
}
export function teamDerivedFacetsForPrimaryTeamChange(
current: LinearIssueAttributeFilter
): LinearIssueAttributeFilter {
// Why: status/assignee/labels are team-scoped ids; priority is global 0..4.
return {
stateIds: [],
priorities: current.priorities,

View File

@ -5,6 +5,16 @@ import { JiraIcon } from '@/components/icons/JiraIcon'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { getTaskPresetQuery } from '@/lib/new-workspace'
import {
LINEAR_DISPLAY_PROPERTIES,
LINEAR_GROUP_BY_OPTIONS,
LINEAR_ORDER_BY_OPTIONS,
LINEAR_VIEW_MODES,
type LinearDisplayProperty,
type LinearGroupBy,
type LinearOrderBy,
type LinearViewMode
} from '../../../shared/linear-issue-view-resume-state'
import type { TaskProvider, TaskViewPresetId } from '../../../shared/types'
export type GitLabTaskFilter = 'opened' | 'merged' | 'closed' | 'all'
@ -30,17 +40,13 @@ export type JiraPreset = { id: JiraPresetId; label: string }
export type GitHubModeButton = { id: GitHubTaskKind | 'project'; label: string }
export type LinearViewMode = 'list' | 'board'
export type LinearMode = 'issues' | 'projects' | 'views' | 'in-orca'
export type LinearGroupBy = 'none' | 'status' | 'assignee' | 'priority' | 'team'
export type LinearOrderBy = 'priority' | 'updated' | 'identifier'
export type LinearDisplayProperty =
| 'state'
| 'priority'
| 'assignee'
| 'team'
| 'labels'
| 'updated'
export type {
LinearDisplayProperty,
LinearGroupBy,
LinearOrderBy,
LinearViewMode
} from '../../../shared/linear-issue-view-resume-state'
export function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
@ -157,43 +163,51 @@ export const getLinearViewOptions = createLocalizedCatalog(
id: LinearViewMode
label: string
Icon: typeof List
}[] => [
{ id: 'list', label: translate('auto.components.TaskPage.a6f7e93d7f', 'List'), Icon: List },
{
id: 'board',
label: translate('auto.components.TaskPage.d747aed72f', 'Board'),
Icon: LayoutGrid
}[] => {
const entries: Record<LinearViewMode, { label: string; Icon: typeof List }> = {
list: { label: translate('auto.components.TaskPage.a6f7e93d7f', 'List'), Icon: List },
board: { label: translate('auto.components.TaskPage.d747aed72f', 'Board'), Icon: LayoutGrid }
}
]
return LINEAR_VIEW_MODES.map((id) => ({ id, ...entries[id] }))
}
)
export const getLinearGroupOptions = createLocalizedCatalog(
(): { id: LinearGroupBy; label: string }[] => [
{ id: 'none', label: translate('auto.components.TaskPage.50387522d7', 'No grouping') },
{ id: 'status', label: translate('auto.components.TaskPage.154b0fa623', 'Status') },
{ id: 'assignee', label: translate('auto.components.TaskPage.d2a876ca53', 'Assignee') },
{ id: 'priority', label: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority') },
{ id: 'team', label: translate('auto.components.TaskPage.a98cbe7664', 'Team') }
]
(): { id: LinearGroupBy; label: string }[] => {
const labels: Record<LinearGroupBy, string> = {
none: translate('auto.components.TaskPage.50387522d7', 'No grouping'),
status: translate('auto.components.TaskPage.154b0fa623', 'Status'),
assignee: translate('auto.components.TaskPage.d2a876ca53', 'Assignee'),
priority: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority'),
team: translate('auto.components.TaskPage.a98cbe7664', 'Team')
}
return LINEAR_GROUP_BY_OPTIONS.map((id) => ({ id, label: labels[id] }))
}
)
export const getLinearOrderOptions = createLocalizedCatalog(
(): { id: LinearOrderBy; label: string }[] => [
{ id: 'priority', label: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority') },
{ id: 'updated', label: translate('auto.components.TaskPage.f362667d55', 'Updated') },
{ id: 'identifier', label: translate('auto.components.TaskPage.d8a517ad89', 'Identifier') }
]
(): { id: LinearOrderBy; label: string }[] => {
const labels: Record<LinearOrderBy, string> = {
priority: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority'),
updated: translate('auto.components.TaskPage.f362667d55', 'Updated'),
identifier: translate('auto.components.TaskPage.d8a517ad89', 'Identifier')
}
return LINEAR_ORDER_BY_OPTIONS.map((id) => ({ id, label: labels[id] }))
}
)
export const getLinearDisplayProperties = createLocalizedCatalog(
(): { id: LinearDisplayProperty; label: string }[] => [
{ id: 'state', label: translate('auto.components.TaskPage.154b0fa623', 'Status') },
{ id: 'priority', label: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority') },
{ id: 'assignee', label: translate('auto.components.TaskPage.d2a876ca53', 'Assignee') },
{ id: 'team', label: translate('auto.components.TaskPage.a98cbe7664', 'Team') },
{ id: 'labels', label: translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels') },
{ id: 'updated', label: translate('auto.components.TaskPage.f362667d55', 'Updated') }
]
(): { id: LinearDisplayProperty; label: string }[] => {
const labels: Record<LinearDisplayProperty, string> = {
state: translate('auto.components.TaskPage.154b0fa623', 'Status'),
priority: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority'),
assignee: translate('auto.components.TaskPage.d2a876ca53', 'Assignee'),
team: translate('auto.components.TaskPage.a98cbe7664', 'Team'),
labels: translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels'),
updated: translate('auto.components.TaskPage.f362667d55', 'Updated')
}
return LINEAR_DISPLAY_PROPERTIES.map((id) => ({ id, label: labels[id] }))
}
)
export const getLinearPriorityLabels = createLocalizedCatalog(

View File

@ -1661,6 +1661,21 @@ describe('createUISlice hydratePersistedUI', () => {
githubItemsQuery: 42,
linearPreset: 'completed',
linearQuery: 'label:bug',
linearIssueView: {
viewMode: 'board',
groupBy: 'nonsense',
orderBy: 'updated',
displayProperties: ['updated', 'bogus', 'state'],
teamPropertyTouched: 'yes',
filtersByWorkspaceId: {
'workspace-1': {
stateIds: ['state-b', 'state-a'],
priorities: [2],
assignee: null,
labelIds: []
}
}
},
jiraPreset: 'reported',
jiraQuery: 99
} as unknown as PersistedUIState['taskResumeState']
@ -1671,10 +1686,66 @@ describe('createUISlice hydratePersistedUI', () => {
githubMode: 'project',
linearPreset: 'completed',
linearQuery: 'label:bug',
linearIssueView: {
viewMode: 'board',
groupBy: 'none',
orderBy: 'updated',
displayProperties: ['state', 'updated'],
teamPropertyTouched: false,
filtersByWorkspaceId: {
'workspace-1': {
stateIds: ['state-a', 'state-b'],
priorities: [2],
assignee: null,
labelIds: []
}
}
},
jiraPreset: 'reported'
})
})
it('drops a corrupt persisted Linear view without losing the rest of the resume state', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
taskResumeState: {
linearQuery: 'label:bug',
linearIssueView: 'board'
} as unknown as PersistedUIState['taskResumeState']
})
)
expect(store.getState().taskResumeState).toEqual({ linearQuery: 'label:bug' })
})
it('drops a corrupt persisted Linear filter without losing the other workspace filters', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
taskResumeState: {
linearIssueView: {
filtersByWorkspaceId: {
'workspace-1': { stateIds: 'not-an-array' },
'workspace-2': {
stateIds: [],
priorities: [1],
assignee: null,
labelIds: []
}
}
}
} as unknown as PersistedUIState['taskResumeState']
})
)
expect(store.getState().taskResumeState?.linearIssueView?.filtersByWorkspaceId).toEqual({
'workspace-2': { stateIds: [], priorities: [1], assignee: null, labelIds: [] }
})
})
it('restores acknowledgedAgentsByPaneKey from persisted UI state', () => {
const now = 1_700_000_000_000
vi.useFakeTimers()

View File

@ -36,6 +36,7 @@ import {
normalizeManualRepoOrder
} from '../../../../shared/manual-repo-order'
import { isTopLevelView } from '../../../../shared/top-level-view'
import { normalizeLinearIssueViewResumeState } from '../../../../shared/linear-issue-view-resume-state'
import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel'
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import {
@ -559,6 +560,12 @@ function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined {
if (typeof input.linearQuery === 'string') {
next.linearQuery = input.linearQuery
}
// Why: normalization drops a malformed preference or filter entry on its own,
// so corrupt Linear view state can't take the rest of the resume state down.
const linearIssueView = normalizeLinearIssueViewResumeState(input.linearIssueView)
if (linearIssueView) {
next.linearIssueView = linearIssueView
}
if (input.linearContext && typeof input.linearContext === 'object') {
const context = input.linearContext as Record<string, unknown>
if (

View File

@ -98,6 +98,31 @@ export function canonicalizeLinearIssueAttributeFilter(
}
}
/**
* Enforces the transport bounds that `canonicalize` does not. Canonical form is
* deduped but unbounded, so a filter built in memory can exceed limits that only
* the throwing parser checks and be rejected the moment it crosses a schema.
*/
export function boundLinearIssueAttributeFilter(
filter: LinearIssueAttributeFilter
): LinearIssueAttributeFilter {
const withinLength = (id: string): boolean =>
id.length > 0 && id.length <= LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH
return {
stateIds: filter.stateIds
.filter(withinLength)
.slice(0, LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS),
priorities: filter.priorities.slice(0, LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES),
assignee:
filter.assignee?.kind === 'user' && !withinLength(filter.assignee.id)
? null
: filter.assignee,
labelIds: filter.labelIds
.filter(withinLength)
.slice(0, LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS)
}
}
export function isEmptyLinearIssueAttributeFilter(
filter: LinearIssueAttributeFilter | null | undefined
): boolean {

View File

@ -0,0 +1,340 @@
import { describe, expect, it } from 'vitest'
import {
emptyLinearIssueAttributeFilter,
LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH,
type LinearIssueAttributeFilter
} from './linear-issue-attribute-filter'
import {
defaultLinearIssueViewResumeState,
LINEAR_DISPLAY_PROPERTIES,
normalizeLinearIssueViewResumeState,
resolveLinearIssueViewResumeState,
selectLinearWorkspaceIssueFilter,
serializeLinearIssueViewResumeState,
setLinearWorkspaceIssueFilter,
type LinearIssueViewResumeState
} from './linear-issue-view-resume-state'
function filter(overrides: Partial<LinearIssueAttributeFilter> = {}): LinearIssueAttributeFilter {
return { ...emptyLinearIssueAttributeFilter(), ...overrides }
}
const FILTER_A = filter({ stateIds: ['state-a'], priorities: [1] })
const FILTER_B = filter({ labelIds: ['label-b'], assignee: { kind: 'user', id: 'user-b' } })
describe('resolveLinearIssueViewResumeState', () => {
it('falls back to list/none/priority with every display property when nothing is persisted', () => {
expect(resolveLinearIssueViewResumeState(undefined)).toEqual({
viewMode: 'list',
groupBy: 'none',
orderBy: 'priority',
displayProperties: [...LINEAR_DISPLAY_PROPERTIES],
teamPropertyTouched: false,
filtersByWorkspaceId: {}
})
})
it('restores a complete persisted view', () => {
const persisted: LinearIssueViewResumeState = {
viewMode: 'board',
groupBy: 'assignee',
orderBy: 'updated',
displayProperties: ['state', 'labels'],
teamPropertyTouched: true,
filtersByWorkspaceId: { 'workspace-a': FILTER_A, 'workspace-b': FILTER_B }
}
expect(resolveLinearIssueViewResumeState(persisted)).toEqual(persisted)
})
it('keeps an empty display-property list, which means every property is hidden', () => {
expect(resolveLinearIssueViewResumeState({ displayProperties: [] }).displayProperties).toEqual(
[]
)
})
it('drops unknown display properties and restores catalog order', () => {
const resolved = resolveLinearIssueViewResumeState({
displayProperties: ['updated', 'bogus', 'state']
})
expect(resolved.displayProperties).toEqual(['state', 'updated'])
})
})
describe('normalizeLinearIssueViewResumeState', () => {
it('drops a malformed preference blob entirely', () => {
expect(normalizeLinearIssueViewResumeState('board')).toBeUndefined()
expect(normalizeLinearIssueViewResumeState(null)).toBeUndefined()
expect(normalizeLinearIssueViewResumeState(['board'])).toBeUndefined()
expect(normalizeLinearIssueViewResumeState({ viewMode: 'grid', groupBy: 42 })).toBeUndefined()
})
it('keeps the valid fields when only some of them are corrupt', () => {
const normalized = normalizeLinearIssueViewResumeState({
viewMode: 'board',
groupBy: 'nonsense',
orderBy: 'updated',
teamPropertyTouched: 'yes'
})
expect(normalized).toEqual({
viewMode: 'board',
groupBy: 'none',
orderBy: 'updated',
displayProperties: [...LINEAR_DISPLAY_PROPERTIES],
teamPropertyTouched: false,
filtersByWorkspaceId: {}
})
})
it('drops only the corrupt workspace filter and keeps the healthy ones', () => {
const normalized = normalizeLinearIssueViewResumeState({
viewMode: 'board',
filtersByWorkspaceId: {
'workspace-a': FILTER_A,
'workspace-broken': { stateIds: 'not-an-array' },
'workspace-partial': { stateIds: ['state-x'] },
'': FILTER_B
}
})
expect(normalized?.viewMode).toBe('board')
expect(normalized?.filtersByWorkspaceId).toEqual({ 'workspace-a': FILTER_A })
})
it('drops empty persisted filters and a __proto__ workspace key', () => {
// JSON.parse (unlike an object literal) makes __proto__ an own key, which is how it would arrive from disk.
const normalized = normalizeLinearIssueViewResumeState({
viewMode: 'board',
filtersByWorkspaceId: JSON.parse(
`{"workspace-empty": ${JSON.stringify(emptyLinearIssueAttributeFilter())},
"__proto__": ${JSON.stringify(FILTER_A)}}`
)
})
expect(normalized?.filtersByWorkspaceId).toEqual({})
expect(({} as Record<string, unknown>).stateIds).toBeUndefined()
})
})
describe('isDefaultLinearIssueViewResumeState', () => {
// Why: this is the sole reason normalize returns undefined, which is what actually
// clears a persisted view when the user puts every setting back to its default.
it('drops a serialized default view so reverting a setting clears what was stored', () => {
expect(
normalizeLinearIssueViewResumeState(
serializeLinearIssueViewResumeState(defaultLinearIssueViewResumeState())
)
).toBeUndefined()
})
it.each([
['viewMode', { viewMode: 'board' as const }],
['groupBy', { groupBy: 'status' as const }],
['orderBy', { orderBy: 'updated' as const }],
['teamPropertyTouched', { teamPropertyTouched: true }],
['displayProperties', { displayProperties: ['state' as const] }],
[
'filtersByWorkspaceId',
{ filtersByWorkspaceId: { 'workspace-1': filter({ priorities: [1] }) } }
]
])('keeps a view that differs from the default in %s', (_label, overrides) => {
expect(
normalizeLinearIssueViewResumeState(
serializeLinearIssueViewResumeState({
...defaultLinearIssueViewResumeState(),
...overrides
})
)
).toBeDefined()
})
})
describe('serializeLinearIssueViewResumeState', () => {
it('round-trips a restored view unchanged', () => {
const restored = resolveLinearIssueViewResumeState({
viewMode: 'board',
groupBy: 'team',
orderBy: 'identifier',
displayProperties: ['labels', 'state'],
teamPropertyTouched: true,
filtersByWorkspaceId: { 'workspace-a': FILTER_A }
})
expect(serializeLinearIssueViewResumeState(restored)).toEqual(restored)
})
it('emits display properties in catalog order regardless of toggle order', () => {
const serialized = serializeLinearIssueViewResumeState({
...defaultLinearIssueViewResumeState(),
displayProperties: new Set(['updated', 'state'] as const)
})
expect(serialized.displayProperties).toEqual(['state', 'updated'])
})
it('omits empty filters and canonicalizes the rest', () => {
const serialized = serializeLinearIssueViewResumeState({
...defaultLinearIssueViewResumeState(),
filtersByWorkspaceId: {
'workspace-empty': emptyLinearIssueAttributeFilter(),
'workspace-a': filter({ labelIds: ['b', 'a', 'a'] })
}
})
expect(serialized.filtersByWorkspaceId).toEqual({
'workspace-a': filter({ labelIds: ['a', 'b'] })
})
})
// Why: bounding runs after the emptiness check, so a filter whose ids are all
// over-length is non-empty going in and empty coming out.
it('omits a filter that only becomes empty once bounded', () => {
const serialized = serializeLinearIssueViewResumeState({
...defaultLinearIssueViewResumeState(),
filtersByWorkspaceId: {
'workspace-over-length': filter({
labelIds: ['x'.repeat(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH + 1)]
})
}
})
expect(serialized.filtersByWorkspaceId).toEqual({})
expect(normalizeLinearIssueViewResumeState(serialized)).toBeUndefined()
})
})
describe('selectLinearWorkspaceIssueFilter', () => {
const filters = { 'workspace-a': FILTER_A, 'workspace-b': FILTER_B }
it('retrieves only the selected workspace filter', () => {
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-a')).toEqual(FILTER_A)
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-b')).toEqual(FILTER_B)
})
it('is unfiltered for a workspace with nothing saved', () => {
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-c')).toEqual(
emptyLinearIssueAttributeFilter()
)
})
it('never applies another workspace filter while Linear is unresolved or disconnected', () => {
expect(selectLinearWorkspaceIssueFilter(filters, null)).toEqual(
emptyLinearIssueAttributeFilter()
)
// Why: an unresolved workspace must read as unfiltered without erasing anything.
expect(filters).toEqual({ 'workspace-a': FILTER_A, 'workspace-b': FILTER_B })
})
it('ignores inherited object keys', () => {
expect(selectLinearWorkspaceIssueFilter(filters, 'toString')).toEqual(
emptyLinearIssueAttributeFilter()
)
})
})
describe('setLinearWorkspaceIssueFilter', () => {
it('keeps workspace filters independent across A -> B -> A', () => {
let filters: Record<string, LinearIssueAttributeFilter> = {}
filters = setLinearWorkspaceIssueFilter(filters, 'workspace-a', FILTER_A)
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-a')).toEqual(FILTER_A)
// Switching to B shows B's (empty) filter without touching A's.
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-b')).toEqual(
emptyLinearIssueAttributeFilter()
)
filters = setLinearWorkspaceIssueFilter(filters, 'workspace-b', FILTER_B)
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-a')).toEqual(FILTER_A)
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-b')).toEqual(FILTER_B)
})
it('returns the same record when the canonical filter is unchanged', () => {
const filters = setLinearWorkspaceIssueFilter({}, 'workspace-a', FILTER_A)
expect(setLinearWorkspaceIssueFilter(filters, 'workspace-a', { ...FILTER_A })).toBe(filters)
// Why: the contract is stability of the CANONICAL filter, so a duplicated or
// unsorted id must not churn the record either.
expect(
setLinearWorkspaceIssueFilter(filters, 'workspace-a', {
...FILTER_A,
stateIds: ['state-a', 'state-a']
})
).toBe(filters)
})
it('removes the entry when the filter is cleared and leaves other workspaces alone', () => {
let filters = setLinearWorkspaceIssueFilter({}, 'workspace-a', FILTER_A)
filters = setLinearWorkspaceIssueFilter(filters, 'workspace-b', FILTER_B)
filters = setLinearWorkspaceIssueFilter(
filters,
'workspace-a',
emptyLinearIssueAttributeFilter()
)
expect(filters).toEqual({ 'workspace-b': FILTER_B })
})
it('refuses an unusable workspace key', () => {
const filters = { 'workspace-a': FILTER_A }
expect(setLinearWorkspaceIssueFilter(filters, '__proto__', FILTER_B)).toBe(filters)
expect(setLinearWorkspaceIssueFilter(filters, '', FILTER_B)).toBe(filters)
})
})
describe('startup sequences', () => {
const persisted = {
viewMode: 'board',
groupBy: 'status',
orderBy: 'updated',
displayProperties: ['state'],
teamPropertyTouched: true,
filtersByWorkspaceId: { 'workspace-a': FILTER_A, 'workspace-b': FILTER_B }
}
it('cold start with the workspace already resolved shows that workspace filter', () => {
const restored = resolveLinearIssueViewResumeState(persisted)
expect(selectLinearWorkspaceIssueFilter(restored.filtersByWorkspaceId, 'workspace-b')).toEqual(
FILTER_B
)
expect(restored.viewMode).toBe('board')
})
it('cold start with the workspace resolving after hydration keeps the restored filter', () => {
const restored = resolveLinearIssueViewResumeState(persisted)
// Hydration lands first with Linear still unresolved...
expect(selectLinearWorkspaceIssueFilter(restored.filtersByWorkspaceId, null)).toEqual(
emptyLinearIssueAttributeFilter()
)
// ...and the workspace resolving later needs no reset effect to surface the filter.
expect(selectLinearWorkspaceIssueFilter(restored.filtersByWorkspaceId, 'workspace-a')).toEqual(
FILTER_A
)
expect(serializeLinearIssueViewResumeState(restored)).toEqual(restored)
})
it('persists the full map when a workspace switch happens during startup', () => {
const restored = resolveLinearIssueViewResumeState(persisted)
// Linear resolves workspace-b mid-startup and the user edits its filter there.
const edited = setLinearWorkspaceIssueFilter(
restored.filtersByWorkspaceId,
'workspace-b',
filter({ priorities: [0] })
)
expect(
serializeLinearIssueViewResumeState({ ...restored, filtersByWorkspaceId: edited })
).toEqual({
...persisted,
filtersByWorkspaceId: {
'workspace-a': FILTER_A,
'workspace-b': filter({ priorities: [0] })
}
})
})
})

View File

@ -0,0 +1,203 @@
// Shared catalogs keep the renderer, resume state, and RPC schema aligned.
import {
boundLinearIssueAttributeFilter,
canonicalizeLinearIssueAttributeFilter,
emptyLinearIssueAttributeFilter,
isEmptyLinearIssueAttributeFilter,
linearIssueAttributeFilterSignature,
parseLinearIssueAttributeFilter,
LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH,
type LinearIssueAttributeFilter
} from './linear-issue-attribute-filter'
export const LINEAR_VIEW_MODES = ['list', 'board'] as const
export const LINEAR_GROUP_BY_OPTIONS = ['none', 'status', 'assignee', 'priority', 'team'] as const
export const LINEAR_ORDER_BY_OPTIONS = ['priority', 'updated', 'identifier'] as const
export const LINEAR_DISPLAY_PROPERTIES = [
'state',
'priority',
'assignee',
'team',
'labels',
'updated'
] as const
export type LinearViewMode = (typeof LINEAR_VIEW_MODES)[number]
export type LinearGroupBy = (typeof LINEAR_GROUP_BY_OPTIONS)[number]
export type LinearOrderBy = (typeof LINEAR_ORDER_BY_OPTIONS)[number]
export type LinearDisplayProperty = (typeof LINEAR_DISPLAY_PROPERTIES)[number]
export const DEFAULT_LINEAR_VIEW_MODE: LinearViewMode = 'list'
export const DEFAULT_LINEAR_GROUP_BY: LinearGroupBy = 'none'
export const DEFAULT_LINEAR_ORDER_BY: LinearOrderBy = 'priority'
export type LinearIssueViewResumeState = {
viewMode: LinearViewMode
groupBy: LinearGroupBy
orderBy: LinearOrderBy
displayProperties: LinearDisplayProperty[]
teamPropertyTouched: boolean
filtersByWorkspaceId: Record<string, LinearIssueAttributeFilter>
}
export type LinearIssueViewSelection = Omit<LinearIssueViewResumeState, 'displayProperties'> & {
displayProperties: Iterable<LinearDisplayProperty>
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isMember<T extends string>(catalog: readonly T[], value: unknown): value is T {
return typeof value === 'string' && (catalog as readonly string[]).includes(value)
}
export function defaultLinearIssueViewResumeState(): LinearIssueViewResumeState {
return {
viewMode: DEFAULT_LINEAR_VIEW_MODE,
groupBy: DEFAULT_LINEAR_GROUP_BY,
orderBy: DEFAULT_LINEAR_ORDER_BY,
displayProperties: [...LINEAR_DISPLAY_PROPERTIES],
teamPropertyTouched: false,
filtersByWorkspaceId: {}
}
}
function isSafeWorkspaceKey(key: string): boolean {
return (
key.length > 0 &&
key.length <= LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH &&
key !== '__proto__'
)
}
function normalizeFiltersByWorkspaceId(value: unknown): Record<string, LinearIssueAttributeFilter> {
if (!isPlainObject(value)) {
return {}
}
const next: Record<string, LinearIssueAttributeFilter> = {}
for (const [workspaceId, filter] of Object.entries(value)) {
if (!isSafeWorkspaceKey(workspaceId)) {
continue
}
let parsed: LinearIssueAttributeFilter
try {
parsed = parseLinearIssueAttributeFilter(filter)
} catch {
continue
}
if (isEmptyLinearIssueAttributeFilter(parsed)) {
continue
}
next[workspaceId] = parsed
}
return next
}
export function normalizeLinearIssueViewResumeState(
value: unknown
): LinearIssueViewResumeState | undefined {
if (!isPlainObject(value)) {
return undefined
}
const next = defaultLinearIssueViewResumeState()
if (isMember(LINEAR_VIEW_MODES, value.viewMode)) {
next.viewMode = value.viewMode
}
if (isMember(LINEAR_GROUP_BY_OPTIONS, value.groupBy)) {
next.groupBy = value.groupBy
}
if (isMember(LINEAR_ORDER_BY_OPTIONS, value.orderBy)) {
next.orderBy = value.orderBy
}
const displayProperties: unknown = value.displayProperties
if (Array.isArray(displayProperties)) {
next.displayProperties = LINEAR_DISPLAY_PROPERTIES.filter((property) =>
displayProperties.includes(property)
)
}
if (typeof value.teamPropertyTouched === 'boolean') {
next.teamPropertyTouched = value.teamPropertyTouched
}
next.filtersByWorkspaceId = normalizeFiltersByWorkspaceId(value.filtersByWorkspaceId)
return isDefaultLinearIssueViewResumeState(next) ? undefined : next
}
export function resolveLinearIssueViewResumeState(value: unknown): LinearIssueViewResumeState {
return normalizeLinearIssueViewResumeState(value) ?? defaultLinearIssueViewResumeState()
}
export function isDefaultLinearIssueViewResumeState(view: LinearIssueViewResumeState): boolean {
return (
view.viewMode === DEFAULT_LINEAR_VIEW_MODE &&
view.groupBy === DEFAULT_LINEAR_GROUP_BY &&
view.orderBy === DEFAULT_LINEAR_ORDER_BY &&
view.teamPropertyTouched === false &&
view.displayProperties.length === LINEAR_DISPLAY_PROPERTIES.length &&
Object.keys(view.filtersByWorkspaceId).length === 0
)
}
export function serializeLinearIssueViewResumeState(
view: LinearIssueViewSelection
): LinearIssueViewResumeState {
const filtersByWorkspaceId: Record<string, LinearIssueAttributeFilter> = {}
for (const [workspaceId, filter] of Object.entries(view.filtersByWorkspaceId)) {
if (!isSafeWorkspaceKey(workspaceId) || isEmptyLinearIssueAttributeFilter(filter)) {
continue
}
const bounded = boundLinearIssueAttributeFilter(canonicalizeLinearIssueAttributeFilter(filter))
if (isEmptyLinearIssueAttributeFilter(bounded)) {
continue
}
filtersByWorkspaceId[workspaceId] = bounded
}
const selectedDisplayProperties = new Set(view.displayProperties)
return {
viewMode: view.viewMode,
groupBy: view.groupBy,
orderBy: view.orderBy,
displayProperties: LINEAR_DISPLAY_PROPERTIES.filter((property) =>
selectedDisplayProperties.has(property)
),
teamPropertyTouched: view.teamPropertyTouched,
filtersByWorkspaceId
}
}
export function selectLinearWorkspaceIssueFilter(
filters: Record<string, LinearIssueAttributeFilter>,
workspaceId: string | null
): LinearIssueAttributeFilter {
if (!workspaceId) {
return emptyLinearIssueAttributeFilter()
}
const filter = Object.prototype.hasOwnProperty.call(filters, workspaceId)
? filters[workspaceId]
: undefined
return filter ? canonicalizeLinearIssueAttributeFilter(filter) : emptyLinearIssueAttributeFilter()
}
export function setLinearWorkspaceIssueFilter(
filters: Record<string, LinearIssueAttributeFilter>,
workspaceId: string,
filter: LinearIssueAttributeFilter
): Record<string, LinearIssueAttributeFilter> {
if (!isSafeWorkspaceKey(workspaceId)) {
return filters
}
const current = selectLinearWorkspaceIssueFilter(filters, workspaceId)
if (
linearIssueAttributeFilterSignature(current) === linearIssueAttributeFilterSignature(filter)
) {
return filters
}
const next = { ...filters }
delete next[workspaceId]
if (isEmptyLinearIssueAttributeFilter(filter)) {
return next
}
next[workspaceId] = canonicalizeLinearIssueAttributeFilter(filter)
return next
}

View File

@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import type { ExecutionHostId } from './execution-host'
import type { LinearIssueViewResumeState } from './linear-issue-view-resume-state'
import type {
RemovedSshTargetTombstone,
SshPtyConsumerRecovery,
@ -3331,6 +3332,8 @@ export type TaskResumeState = {
workspaceId: LinearConcreteWorkspaceId
model?: LinearCustomViewModel
}
/** Issue-list layout, grouping, ordering, columns, and per-workspace attribute filters. */
linearIssueView?: LinearIssueViewResumeState
jiraPreset?: 'assigned' | 'reported' | 'all' | 'done'
jiraQuery?: string
}

View File

@ -0,0 +1,551 @@
/**
* Linear issue list view persistence.
*
* Layout (list/board), grouping, ordering, and per-workspace attribute filters
* ride `taskResumeState.linearIssueView` through ui.set and rehydrate on TaskPage
* mount. Unit tests cover serialize/sanitize; these specs prove the user-visible
* round-trip with a mocked Linear IPC backend.
*/
import { existsSync, readFileSync } from 'node:fs'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { getStoreState, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { TEST_REPO_PATH_FILE } from './global-setup'
const WORKSPACE_A = {
id: 'linear-workspace-a',
displayName: 'Linear E2E User A',
email: 'linear-e2e-a@example.test',
organizationId: 'linear-org-a',
organizationName: 'Alpha Workspace'
} as const
const WORKSPACE_B = {
id: 'linear-workspace-b',
displayName: 'Linear E2E User B',
email: 'linear-e2e-b@example.test',
organizationId: 'linear-org-b',
organizationName: 'Beta Workspace'
} as const
const TEAM_A = {
id: 'linear-team-a',
name: 'Engineering',
key: 'ENG',
workspaceId: WORKSPACE_A.id,
workspaceName: WORKSPACE_A.organizationName
} as const
const TEAM_B = {
id: 'linear-team-b',
name: 'Product',
key: 'PROD',
workspaceId: WORKSPACE_B.id,
workspaceName: WORKSPACE_B.organizationName
} as const
const STATE_A = {
id: 'linear-state-a',
name: 'In Review',
type: 'started',
color: '#888888',
position: 1
} as const
const STATE_B = {
id: 'linear-state-b',
name: 'Todo',
type: 'unstarted',
color: '#666666',
position: 0
} as const
const ISSUE_A = {
id: 'linear-issue-a',
workspaceId: WORKSPACE_A.id,
identifier: 'ENG-100',
title: 'Persist Alpha view prefs',
url: 'https://linear.example.test/ENG-100',
state: { name: STATE_A.name, type: STATE_A.type, color: STATE_A.color },
team: { id: TEAM_A.id, name: TEAM_A.name, key: TEAM_A.key },
labels: [],
labelIds: [],
priority: 2,
updatedAt: '2026-08-04T18:00:00.000Z'
} as const
const ISSUE_B = {
id: 'linear-issue-b',
workspaceId: WORKSPACE_B.id,
identifier: 'PROD-200',
title: 'Persist Beta view prefs',
url: 'https://linear.example.test/PROD-200',
state: { name: STATE_B.name, type: STATE_B.type, color: STATE_B.color },
team: { id: TEAM_B.id, name: TEAM_B.name, key: TEAM_B.key },
labels: [],
labelIds: [],
priority: 4,
updatedAt: '2026-08-04T19:00:00.000Z'
} as const
type LinearIssueViewResume = {
viewMode?: string
groupBy?: string
orderBy?: string
displayProperties?: string[]
teamPropertyTouched?: boolean
filtersByWorkspaceId?: Record<
string,
{
stateIds?: string[]
priorities?: number[]
assignee?: unknown
labelIds?: string[]
}
>
}
const FIXTURE = {
workspaces: [WORKSPACE_A, WORKSPACE_B],
teams: [TEAM_A, TEAM_B],
statesByTeamId: {
[TEAM_A.id]: [STATE_A],
[TEAM_B.id]: [STATE_B]
},
issues: [ISSUE_A, ISSUE_B]
} as const
async function installLinearPersistenceBackend(
electronApp: ElectronApplication,
options?: { multiWorkspace?: boolean }
): Promise<void> {
const multiWorkspace = options?.multiWorkspace === true
await electronApp.evaluate(
({ ipcMain }, payload) => {
const workspaces = payload.multiWorkspace
? payload.fixture.workspaces
: [payload.fixture.workspaces[0]]
let activeWorkspaceId: string = workspaces[0].id
const status = () => ({
connected: true,
viewer: workspaces[0],
workspaces,
activeWorkspaceId,
selectedWorkspaceId: activeWorkspaceId
})
const teamsFor = (workspaceId: string | undefined) => {
if (!workspaceId || workspaceId === 'all') {
return payload.fixture.teams.filter((team) =>
workspaces.some((workspace) => workspace.id === team.workspaceId)
)
}
return payload.fixture.teams.filter((team) => team.workspaceId === workspaceId)
}
const issuesFor = (workspaceId: string | undefined) => {
if (!workspaceId || workspaceId === 'all') {
return payload.fixture.issues.filter((issue) =>
workspaces.some((workspace) => workspace.id === issue.workspaceId)
)
}
return payload.fixture.issues.filter((issue) => issue.workspaceId === workspaceId)
}
ipcMain.removeHandler('linear:status')
ipcMain.handle('linear:status', async () => status())
ipcMain.removeHandler('linear:selectWorkspace')
ipcMain.handle(
'linear:selectWorkspace',
async (_event, args: { workspaceId?: string } | undefined) => {
const next = args?.workspaceId
if (typeof next === 'string' && next.trim()) {
if (next === 'all' || workspaces.some((workspace) => workspace.id === next)) {
activeWorkspaceId = next
}
}
return status()
}
)
ipcMain.removeHandler('linear:listTeams')
ipcMain.handle(
'linear:listTeams',
async (_event, args: { workspaceId?: string } | undefined) =>
teamsFor(args?.workspaceId ?? activeWorkspaceId)
)
ipcMain.removeHandler('linear:listIssues')
ipcMain.handle(
'linear:listIssues',
async (_event, args: { workspaceId?: string } | undefined) => ({
items: issuesFor(args?.workspaceId ?? activeWorkspaceId),
hasMore: false
})
)
ipcMain.removeHandler('linear:teamStates')
ipcMain.handle('linear:teamStates', async (_event, args: { teamId?: string } | undefined) => {
const teamId = args?.teamId
if (!teamId) {
return []
}
return (payload.fixture.statesByTeamId as Record<string, (typeof STATE_A)[]>)[teamId] ?? []
})
ipcMain.removeHandler('linear:teamLabels')
ipcMain.handle('linear:teamLabels', async () => [])
ipcMain.removeHandler('linear:teamMembers')
ipcMain.handle('linear:teamMembers', async () => [])
},
{ fixture: FIXTURE, multiWorkspace }
)
}
async function openLinearTasks(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
await store.getState().checkLinearConnection(true)
store.getState().openTaskPage({ taskSource: 'linear' })
})
}
async function closeTasksPage(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
// Why: store close is locale-stable; the Close tasks label is localized.
store.getState().closeTaskPage()
})
await expect
.poll(async () => getStoreState<string>(page, 'activeView'), { timeout: 5_000 })
.not.toBe('tasks')
}
async function waitForLinearIssuesChrome(page: Page, issueTitle: string): Promise<void> {
await expect
.poll(async () => getStoreState<string>(page, 'activeView'), { timeout: 10_000 })
.toBe('tasks')
await expect(page.getByRole('button', { name: 'Filters', exact: true })).toBeVisible({
timeout: 15_000
})
await expect(page.getByText(issueTitle, { exact: true })).toBeVisible({ timeout: 15_000 })
}
async function openViewMenu(page: Page): Promise<void> {
const viewButton = page.getByRole('button', { name: 'View', exact: true })
await expect(viewButton).toBeVisible()
if ((await page.getByRole('menuitemradio', { name: 'Board' }).count()) > 0) {
return
}
await viewButton.click()
await expect(page.getByRole('menuitemradio', { name: 'Board' })).toBeVisible()
}
async function dismissOverlayChrome(page: Page): Promise<void> {
// Why: open Radix menus aria-hide/inert page chrome so later getByRole for
// Filters/chips fails. Outside force-clicks on inert chrome do not close modal
// menus in headless Electron, and the open portal covers the View trigger.
// Esc closes the menu/popover (TaskPage leaves Esc alone while those are open).
if (
(await page.getByRole('menuitemradio', { name: 'Board' }).count()) > 0 ||
(await page.locator('[data-slot="popover-content"]').count()) > 0
) {
await page.keyboard.press('Escape')
}
await expect(page.getByRole('menuitemradio', { name: 'Board' })).toHaveCount(0)
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
}
async function selectViewMenuRadio(page: Page, name: string): Promise<void> {
await openViewMenu(page)
const item = page.getByRole('menuitemradio', { name })
await expect(item).toBeVisible()
// Why: Radix menus can briefly report "not stable" while the issues list reflows.
await item.click({ force: true })
}
async function setLinearViewPreferences(
page: Page,
options: { viewMode: 'List' | 'Board'; groupBy: string; orderBy: string }
): Promise<void> {
// Why: grouping/ordering first, then Board last — Board remounts the issue
// surface and would detach an open menu if applied earlier.
await selectViewMenuRadio(page, options.groupBy)
await selectViewMenuRadio(page, options.orderBy)
await selectViewMenuRadio(page, options.viewMode)
await dismissOverlayChrome(page)
// Toolbar List/Board toggle is md+ only; prove mode via the View menu radios.
await openViewMenu(page)
await expect(page.getByRole('menuitemradio', { name: options.viewMode })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: options.groupBy })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: options.orderBy })).toHaveAttribute(
'aria-checked',
'true'
)
await dismissOverlayChrome(page)
}
async function applyStatusFilter(page: Page, statusName: string): Promise<void> {
await dismissOverlayChrome(page)
const filtersButton = page.getByRole('button', { name: 'Filters', exact: true })
await filtersButton.click()
const popover = page.locator('[data-slot="popover-content"]')
await popover.getByRole('button', { name: 'Status', exact: true }).click()
await popover.getByText(statusName, { exact: true }).click()
await filtersButton.click()
await expect(popover).toHaveCount(0)
}
async function applyPriorityFilter(page: Page, priorityLabel: string): Promise<void> {
const filtersButton = page.getByRole('button', { name: 'Filters', exact: true })
await filtersButton.click()
const popover = page.locator('[data-slot="popover-content"]')
await popover.getByRole('button', { name: 'Priority', exact: true }).click()
await popover.getByText(priorityLabel, { exact: true }).click()
await filtersButton.click()
await expect(popover).toHaveCount(0)
}
async function waitForLinearIssueViewPersisted(
page: Page,
predicate: (view: LinearIssueViewResume | undefined) => boolean
): Promise<void> {
await expect
.poll(
async () => {
const resume = await getStoreState<{ linearIssueView?: LinearIssueViewResume } | undefined>(
page,
'taskResumeState'
)
return predicate(resume?.linearIssueView) ? 'ready' : 'pending'
},
{
timeout: 10_000,
message: 'Linear issue view preferences did not persist to taskResumeState'
}
)
.toBe('ready')
}
async function expectRestoredLinearView(page: Page): Promise<void> {
await openViewMenu(page)
await expect(page.getByRole('menuitemradio', { name: 'Board' })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: 'Status' })).toHaveAttribute(
'aria-checked',
'true'
)
await expect(page.getByRole('menuitemradio', { name: 'Updated' })).toHaveAttribute(
'aria-checked',
'true'
)
await dismissOverlayChrome(page)
// Board surface renders status sections; flat list shows a Key column header.
await expect(page.getByText('Key', { exact: true })).toHaveCount(0)
}
async function switchLinearWorkspace(page: Page, organizationName: string): Promise<void> {
// Why: multi-workspace trigger label is "Org / All teams".
const trigger = page
.locator('button[role="combobox"]')
.filter({ hasText: /All teams|Alpha|Beta|All workspaces/ })
.first()
await expect(trigger).toBeVisible({ timeout: 10_000 })
await trigger.click()
const popover = page.locator('[data-slot="popover-content"]')
await popover.getByText(organizationName, { exact: true }).click()
await expect(popover).toHaveCount(0)
}
function seededRepoPathOrSkip(): string {
const repoPath = existsSync(TEST_REPO_PATH_FILE)
? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
: ''
test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo')
return repoPath
}
test.describe('Linear issue view persistence', () => {
test('preserves view mode, grouping, ordering, and filters across a tasks remount', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await installLinearPersistenceBackend(electronApp)
await openLinearTasks(orcaPage)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await setLinearViewPreferences(orcaPage, {
viewMode: 'Board',
groupBy: 'Status',
orderBy: 'Updated'
})
await applyStatusFilter(orcaPage, STATE_A.name)
await waitForLinearIssueViewPersisted(orcaPage, (view) => {
if (
!view ||
view.viewMode !== 'board' ||
view.groupBy !== 'status' ||
view.orderBy !== 'updated'
) {
return false
}
const filter = view.filtersByWorkspaceId?.[WORKSPACE_A.id]
return Boolean(filter?.stateIds?.includes(STATE_A.id))
})
// User-visible before remount.
await expectRestoredLinearView(orcaPage)
const statusChip = orcaPage.getByRole('button', { name: 'Remove Status filter' }).locator('..')
await expect(statusChip).toContainText(STATE_A.name)
await closeTasksPage(orcaPage)
await openLinearTasks(orcaPage)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await expectRestoredLinearView(orcaPage)
await expect(
orcaPage.getByRole('button', { name: 'Remove Status filter' }).locator('..')
).toContainText(STATE_A.name)
// Board surface, not the flat list column header.
await expect(orcaPage.getByText(STATE_A.name, { exact: true }).first()).toBeVisible()
})
test('keeps attribute filters scoped per Linear workspace', async ({ electronApp, orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await installLinearPersistenceBackend(electronApp, { multiWorkspace: true })
await openLinearTasks(orcaPage)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await applyPriorityFilter(orcaPage, 'High')
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('High')
await waitForLinearIssueViewPersisted(orcaPage, (view) => {
const filter = view?.filtersByWorkspaceId?.[WORKSPACE_A.id]
return Boolean(filter?.priorities?.includes(2))
})
await switchLinearWorkspace(orcaPage, WORKSPACE_B.organizationName)
await waitForLinearIssuesChrome(orcaPage, ISSUE_B.title)
// Workspace B starts unfiltered — Alpha's High must not leak.
await expect(orcaPage.getByRole('button', { name: 'Remove Priority filter' })).toHaveCount(0)
await applyPriorityFilter(orcaPage, 'Low')
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('Low')
await waitForLinearIssueViewPersisted(orcaPage, (view) => {
const a = view?.filtersByWorkspaceId?.[WORKSPACE_A.id]
const b = view?.filtersByWorkspaceId?.[WORKSPACE_B.id]
return Boolean(a?.priorities?.includes(2) && b?.priorities?.includes(4))
})
await switchLinearWorkspace(orcaPage, WORKSPACE_A.organizationName)
await waitForLinearIssuesChrome(orcaPage, ISSUE_A.title)
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('High')
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).not.toContainText('Low')
await switchLinearWorkspace(orcaPage, WORKSPACE_B.organizationName)
await waitForLinearIssuesChrome(orcaPage, ISSUE_B.title)
await expect(
orcaPage.getByRole('button', { name: 'Remove Priority filter' }).locator('..')
).toContainText('Low')
})
})
test('restores Linear view preferences after an app restart', async (// oxlint-disable-next-line no-empty-pattern -- Playwright fixture opt-out
{}, testInfo) => {
test.setTimeout(300_000)
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
let secondApp: ElectronApplication | null = null
try {
const first = await session.launch()
firstApp = first.app
await waitForSessionReady(first.page)
await attachRepoAndOpenTerminal(first.page, repoPath)
await installLinearPersistenceBackend(firstApp)
await openLinearTasks(first.page)
await waitForLinearIssuesChrome(first.page, ISSUE_A.title)
await setLinearViewPreferences(first.page, {
viewMode: 'Board',
groupBy: 'Status',
orderBy: 'Updated'
})
await applyStatusFilter(first.page, STATE_A.name)
await waitForLinearIssueViewPersisted(first.page, (view) => {
if (
!view ||
view.viewMode !== 'board' ||
view.groupBy !== 'status' ||
view.orderBy !== 'updated'
) {
return false
}
return Boolean(view.filtersByWorkspaceId?.[WORKSPACE_A.id]?.stateIds?.includes(STATE_A.id))
})
await expectRestoredLinearView(first.page)
await session.close(firstApp)
firstApp = null
const second = await session.launch()
secondApp = second.app
await waitForSessionReady(second.page)
// Why: IPC mocks die with the process; reinstall before reopening Linear.
await installLinearPersistenceBackend(secondApp)
await openLinearTasks(second.page)
await waitForLinearIssuesChrome(second.page, ISSUE_A.title)
await expectRestoredLinearView(second.page)
await expect(
second.page.getByRole('button', { name: 'Remove Status filter' }).locator('..')
).toContainText(STATE_A.name)
} finally {
for (const app of [secondApp, firstApp]) {
if (!app) {
continue
}
try {
await session.close(app)
} catch {
// best-effort cleanup
}
}
await session.dispose()
}
})