diff --git a/src/main/runtime/rpc/methods/client-ui-linear-issue-view.test.ts b/src/main/runtime/rpc/methods/client-ui-linear-issue-view.test.ts new file mode 100644 index 000000000..3403feb58 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-linear-issue-view.test.ts @@ -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 } { + 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 } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts index f0a8e814e..e7bbca74a 100644 --- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -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 > = 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, + z.infer +> = 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, + z.infer +> = true +void _linearIssueViewExtraKeys diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts index 555e8f44c..29d205e60 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts @@ -13,6 +13,16 @@ export type AssertNoMissingKeys> ? true : { missingFromSchema: Exclude } +/** + * 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> = + Exclude extends never + ? true + : { absentFromType: Exclude } + /** * 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 diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 2b6aef96d..8358490a9 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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( } } -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(null) const [linearSearchInput, setLinearSearchInput] = useState('') const [appliedLinearSearch, setAppliedLinearSearch] = useState('') - const [linearAttributeFilter, setLinearAttributeFilter] = useState( - () => emptyLinearIssueAttributeFilter() + const [linearIssueFiltersByWorkspaceId, setLinearIssueFiltersByWorkspaceId] = useState< + Record + >(() => ({})) + const linearAttributeFilterWorkspaceId = + selectedLinearWorkspaceId && selectedLinearWorkspaceId !== 'all' + ? selectedLinearWorkspaceId + : null + const linearAttributeFilter = useMemo( + () => + selectLinearWorkspaceIssueFilter( + linearIssueFiltersByWorkspaceId, + linearAttributeFilterWorkspaceId + ), + [linearAttributeFilterWorkspaceId, linearIssueFiltersByWorkspaceId] ) - const linearAttributeFilterSignatureRef = useRef( - linearIssueAttributeFilterSignature(emptyLinearIssueAttributeFilter()) - ) - const linearPrimaryTeamIdRef = useRef(null) - const previousLinearWorkspaceIdForFiltersRef = useRef(undefined) - const [linearViewMode, setLinearViewMode] = useState('list') - const [linearGroupBy, setLinearGroupBy] = useState('none') - const [linearOrderBy, setLinearOrderBy] = useState('priority') + const linearAttributeFilterReadRef = useRef(null) + const linearPrimaryTeamRef = useRef(null) + const [linearViewMode, setLinearViewMode] = useState(DEFAULT_LINEAR_VIEW_MODE) + const [linearGroupBy, setLinearGroupBy] = useState(DEFAULT_LINEAR_GROUP_BY) + const [linearOrderBy, setLinearOrderBy] = useState(DEFAULT_LINEAR_ORDER_BY) const [linearDisplayProperties, setLinearDisplayProperties] = useState< ReadonlySet - >(() => 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 { 0} settings={linearTaskSourceContext ?? settings} /> ) : null} 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 845ed09b0..8fa395695 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 @@ -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( + 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( + + ) + }) + } + + renderWith(false) + expect(onChange).not.toHaveBeenCalled() + + renderWith(true) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ stateIds: [], labelIds: [], assignee: null }) + ) + }) }) 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 9cf550e2f..85e6553ba 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx @@ -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({ - {isAllWorkspaces ? ( + {!scopedWorkspaceId ? (

{translate( diff --git a/src/renderer/src/components/task-page-linear-issue-request.test.ts b/src/renderer/src/components/task-page-linear-issue-request.test.ts index 71cdbca61..f2ce103f6 100644 --- a/src/renderer/src/components/task-page-linear-issue-request.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-request.test.ts @@ -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: [], diff --git a/src/renderer/src/components/task-page-linear-issue-request.ts b/src/renderer/src/components/task-page-linear-issue-request.ts index ca19149ff..3e85be2de 100644 --- a/src/renderer/src/components/task-page-linear-issue-request.ts +++ b/src/renderer/src/components/task-page-linear-issue-request.ts @@ -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, diff --git a/src/renderer/src/components/task-page-localized-options.tsx b/src/renderer/src/components/task-page-localized-options.tsx index c4498969d..3eb0662cd 100644 --- a/src/renderer/src/components/task-page-localized-options.tsx +++ b/src/renderer/src/components/task-page-localized-options.tsx @@ -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 = { + 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 = { + 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 = { + 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 = { + 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( diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index dbd81acee..4a1d36dec 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index b738f18f3..f44eabac0 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 if ( diff --git a/src/shared/linear-issue-attribute-filter.ts b/src/shared/linear-issue-attribute-filter.ts index d856b4ef9..62673a2a7 100644 --- a/src/shared/linear-issue-attribute-filter.ts +++ b/src/shared/linear-issue-attribute-filter.ts @@ -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 { diff --git a/src/shared/linear-issue-view-resume-state.test.ts b/src/shared/linear-issue-view-resume-state.test.ts new file mode 100644 index 000000000..806977e28 --- /dev/null +++ b/src/shared/linear-issue-view-resume-state.test.ts @@ -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 { + 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).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 = {} + 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] }) + } + }) + }) +}) diff --git a/src/shared/linear-issue-view-resume-state.ts b/src/shared/linear-issue-view-resume-state.ts new file mode 100644 index 000000000..11fbb9675 --- /dev/null +++ b/src/shared/linear-issue-view-resume-state.ts @@ -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 +} + +export type LinearIssueViewSelection = Omit & { + displayProperties: Iterable +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isMember(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 { + if (!isPlainObject(value)) { + return {} + } + const next: Record = {} + 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 = {} + 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, + 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, + workspaceId: string, + filter: LinearIssueAttributeFilter +): Record { + 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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index cfa041d39..c351d6994 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 } diff --git a/tests/e2e/linear-issue-view-persistence.spec.ts b/tests/e2e/linear-issue-view-persistence.spec.ts new file mode 100644 index 000000000..ce892543b --- /dev/null +++ b/tests/e2e/linear-issue-view-persistence.spec.ts @@ -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 { + 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)[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 { + 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 { + 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(page, 'activeView'), { timeout: 5_000 }) + .not.toBe('tasks') +} + +async function waitForLinearIssuesChrome(page: Page, issueTitle: string): Promise { + await expect + .poll(async () => getStoreState(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 { + 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 { + // 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + // 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() + } +})