From ce20a109dab68a3d2b4844e204eb8a0454069a99 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 7 Aug 2026 20:41:59 -0700 Subject: [PATCH] Persist the Linear issue list view and per-workspace filters (#12710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: m4air Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../client-ui-linear-issue-view.test.ts | 187 ++++++ .../rpc/methods/task-resume-state-schema.ts | 60 +- .../rpc/methods/ui-state-schema-parity.ts | 10 + src/renderer/src/components/TaskPage.tsx | 175 ++++-- ...-issue-attribute-filter-dropdowns.test.tsx | 79 ++- ...inear-issue-attribute-filter-dropdowns.tsx | 26 +- .../task-page-linear-issue-request.test.ts | 68 ++- .../task-page-linear-issue-request.ts | 31 +- .../task-page-localized-options.tsx | 88 +-- src/renderer/src/store/slices/ui.test.ts | 71 +++ src/renderer/src/store/slices/ui.ts | 7 + src/shared/linear-issue-attribute-filter.ts | 25 + .../linear-issue-view-resume-state.test.ts | 340 +++++++++++ src/shared/linear-issue-view-resume-state.ts | 203 +++++++ src/shared/types.ts | 3 + .../e2e/linear-issue-view-persistence.spec.ts | 551 ++++++++++++++++++ 16 files changed, 1813 insertions(+), 111 deletions(-) create mode 100644 src/main/runtime/rpc/methods/client-ui-linear-issue-view.test.ts create mode 100644 src/shared/linear-issue-view-resume-state.test.ts create mode 100644 src/shared/linear-issue-view-resume-state.ts create mode 100644 tests/e2e/linear-issue-view-persistence.spec.ts 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() + } +})