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 index 3841aac3325..3403feb58e3 100644 --- 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 @@ -6,7 +6,6 @@ import { } from '../../../../shared/linear-issue-attribute-filter' import { defaultLinearIssueViewResumeState, - LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES, serializeLinearIssueViewResumeState } from '../../../../shared/linear-issue-view-resume-state' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -106,8 +105,6 @@ describe('ui.set Linear issue view resume state', () => { } } ], - // Why: the renderer caps the map through `trimToMostRecentWorkspaces` and bounds - // its keys, so these two limits must stay equal to the schema's or every write drops. [ 'an over-long workspace key', { @@ -121,18 +118,6 @@ describe('ui.set Linear issue view resume state', () => { } } } - ], - [ - 'more workspaces than the persisted limit', - { - ...VALID_VIEW, - filtersByWorkspaceId: Object.fromEntries( - Array.from({ length: LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES + 1 }, (_, index) => [ - `workspace-${index}`, - { stateIds: [`state-${index}`], priorities: [], assignee: null, labelIds: [] } - ]) - ) - } ] ])('drops only the view when it has %s', async (_label, linearIssueView) => { const { dispatcher, updateUIState } = makeDispatcher() 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 0d5ecded4c4..e7bbca74a80 100644 --- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -3,7 +3,6 @@ import { LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH } from '../../../../shared/ import { LINEAR_DISPLAY_PROPERTIES, LINEAR_GROUP_BY_OPTIONS, - LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES, LINEAR_ORDER_BY_OPTIONS, LINEAR_VIEW_MODES } from '../../../../shared/linear-issue-view-resume-state' @@ -22,15 +21,10 @@ const LinearIssueViewResumeState = 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 - ) - .refine( - (filters) => Object.keys(filters).length <= LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES, - { message: `At most ${LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES} workspace filters` } - ) + filtersByWorkspaceId: z.record( + z.string().min(1).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH), + LinearIssueAttributeFilterSchema + ) }) .strict() diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 5d5232a3bbd..4d16163dc02 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -231,7 +231,6 @@ import { isLinearIssueSearchActive, shouldClearTeamDerivedFacets, shouldForceLinearIssueListRead, - shouldPersistLinearIssueView, teamDerivedFacetsForPrimaryTeamChange, type LinearIssueListFilterRead, type LinearPrimaryTeamObservation @@ -4386,9 +4385,6 @@ export default function TaskPage(): React.JSX.Element { const [linearIssueFiltersByWorkspaceId, setLinearIssueFiltersByWorkspaceId] = useState< Record >(() => ({})) - // Why: facet ids are workspace-scoped, so the active filter is *derived* from the - // selected workspace — no effect resets it on switch, and an unresolved or - // cross-workspace ("all") selection reads as unfiltered without erasing anything. const linearAttributeFilterWorkspaceId = selectedLinearWorkspaceId && selectedLinearWorkspaceId !== 'all' ? selectedLinearWorkspaceId @@ -4401,7 +4397,6 @@ export default function TaskPage(): React.JSX.Element { ), [linearAttributeFilterWorkspaceId, linearIssueFiltersByWorkspaceId] ) - // Why: null until the first list read, so a restored filter isn't mistaken for a change. const linearAttributeFilterReadRef = useRef(null) const linearPrimaryTeamRef = useRef(null) const [linearViewMode, setLinearViewMode] = useState(DEFAULT_LINEAR_VIEW_MODE) @@ -4687,8 +4682,6 @@ export default function TaskPage(): React.JSX.Element { setLinearSearchInput(linearQuery) setAppliedLinearSearch(linearQuery) - // Why: filters are restored for every workspace at once, so hydration doesn't - // have to wait for — or re-run on — Linear resolving which workspace is selected. const linearIssueView = resolveLinearIssueViewResumeState(taskResumeState?.linearIssueView) setLinearViewMode(linearIssueView.viewMode) setLinearGroupBy(linearIssueView.groupBy) @@ -5205,7 +5198,6 @@ export default function TaskPage(): React.JSX.Element { 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. if (linearAttributeFilterWorkspaceId) { setLinearIssueFiltersByWorkspaceId((previous) => setLinearWorkspaceIssueFilter(previous, linearAttributeFilterWorkspaceId, next) @@ -5219,8 +5211,6 @@ export default function TaskPage(): React.JSX.Element { ) useEffect(() => { - // Why: linearTeamOptions falls back to teams scraped from the still-stale issue list - // mid-switch; only fetched teams identify the workspace the primary team belongs to. const nextTeamId = availableTeams.length > 0 ? (linearAttributePrimaryTeam?.id ?? null) : null if (!nextTeamId) { return @@ -7411,15 +7401,12 @@ export default function TaskPage(): React.JSX.Element { setTaskResumeState({ linearQuery: appliedLinearSearch.trim() }) }, [appliedLinearSearch, setTaskResumeState, taskResumeApplied]) - // Why: watch the values rather than each setter, so effect-driven changes (the - // primary-team facet reset) persist too. The payload is workspace-independent, - // so selecting a workspace mid-startup can't write a half-restored state. useEffect(() => { - const persistReady = linearViewPersistReadyRef.current - if (taskResumeApplied) { - linearViewPersistReadyRef.current = true + if (!taskResumeApplied) { + return } - if (!shouldPersistLinearIssueView({ taskResumeApplied, persistReady })) { + if (!linearViewPersistReadyRef.current) { + linearViewPersistReadyRef.current = true return } setTaskResumeState({ 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 09bac285c07..f2ce103f645 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 @@ -5,7 +5,6 @@ import { isLinearIssueSearchActive, shouldClearTeamDerivedFacets, shouldForceLinearIssueListRead, - shouldPersistLinearIssueView, teamDerivedFacetsForPrimaryTeamChange } from './task-page-linear-issue-request' import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' @@ -97,20 +96,6 @@ describe('task-page-linear-issue-request', () => { ).toBe(false) }) - it('persists the Linear view only after hydration has taken its first pass', () => { - expect(shouldPersistLinearIssueView({ taskResumeApplied: false, persistReady: false })).toBe( - false - ) - // Why: the first pass after hydration carries the restored values, not a user change. - expect(shouldPersistLinearIssueView({ taskResumeApplied: true, persistReady: false })).toBe( - false - ) - expect(shouldPersistLinearIssueView({ taskResumeApplied: true, persistReady: true })).toBe(true) - expect(shouldPersistLinearIssueView({ taskResumeApplied: false, persistReady: true })).toBe( - false - ) - }) - it('clears team-derived facets only when the primary team changes within one workspace', () => { expect( shouldClearTeamDerivedFacets({ 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 440cce70285..3e85be2def0 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,11 +53,9 @@ export function buildLinearIssueListRequestSignature(options: { return `${sourceScope}::${workspace}::list::${options.filter ?? 'all'}::${options.limit}::${signature}` } -/** The last list read's filter, scoped to the workspace it was read for. */ export type LinearIssueListFilterRead = { workspaceId: string | null; signature: string } export function shouldForceLinearIssueListRead(options: { - /** `null` until this session's first list read; a restored filter is a baseline, not a change. */ previousFilterRead: LinearIssueListFilterRead | null nextFilterRead: LinearIssueListFilterRead refreshForced: boolean @@ -66,41 +63,17 @@ export function shouldForceLinearIssueListRead(options: { if (options.refreshForced) { return true } - // Why: forcing the very first read would bypass warm cache and show the blocking - // spinner on every cold start that restores a persisted filter. if (options.previousFilterRead === null) { return false } - // Why: the list cache is keyed by workspace, so each workspace's filter is its own - // baseline — switching restores a different filter, which is not a filter *change*. if (options.previousFilterRead.workspaceId !== options.nextFilterRead.workspaceId) { return false } - // Why: filter signature changes always need a current server read, even when - // returning to a previously cached signature that is still warm. return options.previousFilterRead.signature !== options.nextFilterRead.signature } -/** - * The persist effect must skip its first pass after hydration: that pass carries the - * values just restored, so writing them back is a no-op at best and, before the - * restore lands, a default-state stomp. - */ -export function shouldPersistLinearIssueView(options: { - taskResumeApplied: boolean - persistReady: boolean -}): boolean { - return options.taskResumeApplied && options.persistReady -} - -/** Where a primary team was observed; the team catalog is Linear-workspace scoped. */ export type LinearPrimaryTeamObservation = { workspaceId: string | null; teamId: string } -/** - * Only an in-workspace primary-team change invalidates team-scoped facets. A - * workspace switch also changes the primary team, but it swaps in that workspace's - * own persisted filter — clearing there would erase the restored state. - */ export function shouldClearTeamDerivedFacets(options: { previous: LinearPrimaryTeamObservation | null next: LinearPrimaryTeamObservation @@ -115,7 +88,6 @@ export function shouldClearTeamDerivedFacets(options: { 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-linear-issue-view-boundary.test.ts b/src/renderer/src/components/task-page-linear-issue-view-boundary.test.ts deleted file mode 100644 index bb2728002b9..00000000000 --- a/src/renderer/src/components/task-page-linear-issue-view-boundary.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Why: TaskPage is too large to render in a unit test, so the decisions live in -// task-page-linear-issue-request.ts where they are unit-tested directly. What is -// left to pin here is that the effects delegate to them rather than re-deriving. -const TASK_PAGE_SOURCE = readFileSync(join(__dirname, 'TaskPage.tsx'), 'utf8') - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -function persistEffectSource(): string { - const write = TASK_PAGE_SOURCE.indexOf('linearIssueView: serializeLinearIssueViewResumeState(') - expect(write).toBeGreaterThan(0) - const start = TASK_PAGE_SOURCE.lastIndexOf('useEffect(() => {', write) - expect(start).toBeGreaterThan(0) - return TASK_PAGE_SOURCE.slice(start, TASK_PAGE_SOURCE.indexOf('])', write)) -} - -describe('TaskPage Linear issue view persistence boundary', () => { - it('writes the persisted view from exactly one effect, gated by the tested predicate', () => { - expect(TASK_PAGE_SOURCE.match(/linearIssueView:/g)).toHaveLength(1) - - const effect = persistEffectSource() - // Why: the gate is `shouldPersistLinearIssueView` in task-page-linear-issue-request.ts, - // which owns the hydration-first-pass skip; inlining it here would leave it untested. - expect(effect).toContain('shouldPersistLinearIssueView({') - expect( - sourceBetween(effect, 'shouldPersistLinearIssueView({', 'setTaskResumeState(') - ).toContain('return') - }) - - it('keeps the persisted payload independent of the selected workspace', () => { - // Why: the payload carries every workspace's filter, so a workspace resolving - // or switching mid-startup cannot make the write depend on effect ordering. - expect(persistEffectSource()).not.toContain('selectedLinearWorkspaceId') - }) - - it('restores every workspace filter during hydration', () => { - const hydration = sourceBetween( - TASK_PAGE_SOURCE, - 'if (taskResumeAppliedRef.current || !persistedUIReady', - 'taskResumeAppliedRef.current = true' - ) - expect(hydration).toContain( - 'setLinearIssueFiltersByWorkspaceId(linearIssueView.filtersByWorkspaceId)' - ) - }) - - it('derives the active filter from the selected workspace instead of resetting it', () => { - expect(TASK_PAGE_SOURCE).toContain('selectLinearWorkspaceIssueFilter(') - // Why: a mutable previous-workspace ref plus a reset effect is exactly the - // race this model replaces — a cold start would clear the restored filter. - expect(TASK_PAGE_SOURCE).not.toContain('previousLinearWorkspaceIdForFiltersRef') - expect(TASK_PAGE_SOURCE).not.toContain('setLinearAttributeFilter(') - }) -}) diff --git a/src/renderer/src/components/task-page-localized-options.tsx b/src/renderer/src/components/task-page-localized-options.tsx index af9eed3e643..3eb0662cdf0 100644 --- a/src/renderer/src/components/task-page-localized-options.tsx +++ b/src/renderer/src/components/task-page-localized-options.tsx @@ -41,11 +41,6 @@ export type JiraPreset = { id: JiraPresetId; label: string } export type GitHubModeButton = { id: GitHubTaskKind | 'project'; label: string } export type LinearMode = 'issues' | 'projects' | 'views' | 'in-orca' -// Why: re-exported from the persisted-state catalogs so the view options the user -// picks and the ones TaskResumeState/the ui.set schema accept cannot drift in TYPE. -// The Linear catalogs below are keyed by the same constants so they cannot drift in -// MEMBERSHIP either: a new option without a label is a typecheck error, not a value -// that persists fine yet never renders. export type { LinearDisplayProperty, LinearGroupBy, @@ -211,7 +206,6 @@ export const getLinearDisplayProperties = createLocalizedCatalog( labels: translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels'), updated: translate('auto.components.TaskPage.f362667d55', 'Updated') } - // Why: catalog order is also the display-property serialization order. return LINEAR_DISPLAY_PROPERTIES.map((id) => ({ id, label: labels[id] })) } ) diff --git a/src/shared/linear-issue-view-resume-state.test.ts b/src/shared/linear-issue-view-resume-state.test.ts index 0de58a76b2c..806977e28be 100644 --- a/src/shared/linear-issue-view-resume-state.test.ts +++ b/src/shared/linear-issue-view-resume-state.test.ts @@ -7,9 +7,7 @@ import { import { defaultLinearIssueViewResumeState, LINEAR_DISPLAY_PROPERTIES, - LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES, normalizeLinearIssueViewResumeState, - orderLinearDisplayProperties, resolveLinearIssueViewResumeState, selectLinearWorkspaceIssueFilter, serializeLinearIssueViewResumeState, @@ -118,20 +116,6 @@ describe('normalizeLinearIssueViewResumeState', () => { expect(normalized?.filtersByWorkspaceId).toEqual({}) expect(({} as Record).stateIds).toBeUndefined() }) - - it('caps the persisted workspace filters', () => { - const filtersByWorkspaceId: Record = {} - for (let index = 0; index < LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES + 3; index += 1) { - filtersByWorkspaceId[`workspace-${index}`] = filter({ stateIds: [`state-${index}`] }) - } - - const normalized = normalizeLinearIssueViewResumeState({ filtersByWorkspaceId }) - - const keys = Object.keys(normalized?.filtersByWorkspaceId ?? {}) - expect(keys).toHaveLength(LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES) - // Why: writes append, so the most recently filtered workspaces are the survivors. - expect(keys.at(-1)).toBe(`workspace-${LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES + 2}`) - }) }) describe('isDefaultLinearIssueViewResumeState', () => { @@ -188,7 +172,6 @@ describe('serializeLinearIssueViewResumeState', () => { }) expect(serialized.displayProperties).toEqual(['state', 'updated']) - expect(orderLinearDisplayProperties(['labels', 'priority'])).toEqual(['priority', 'labels']) }) it('omits empty filters and canonicalizes the rest', () => { @@ -267,44 +250,6 @@ describe('setLinearWorkspaceIssueFilter', () => { expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-b')).toEqual(FILTER_B) }) - // Why: re-filtering an existing workspace used to reassign the key in place, leaving - // the workspace you just used at the head — first in line to be evicted. - it('evicts the least recently used workspace, not the one just re-filtered', () => { - let filters: Record = {} - for (let index = 0; index < LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES; index += 1) { - filters = setLinearWorkspaceIssueFilter( - filters, - `workspace-${index}`, - filter({ stateIds: [`state-${index}`] }) - ) - } - filters = setLinearWorkspaceIssueFilter(filters, 'workspace-0', FILTER_A) - filters = setLinearWorkspaceIssueFilter(filters, 'workspace-fresh', FILTER_B) - - expect(Object.keys(filters)).toHaveLength(LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES) - expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-0')).toEqual(FILTER_A) - expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-1')).toEqual( - emptyLinearIssueAttributeFilter() - ) - }) - - // Why: array-index-like keys enumerate before string keys regardless of insertion - // order, so key order alone would evict the entry this very write just added. - it('keeps an integer-like workspace key that the write just added', () => { - let filters: Record = {} - for (let index = 0; index < LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES; index += 1) { - filters = setLinearWorkspaceIssueFilter( - filters, - `workspace-${index}`, - filter({ stateIds: [`state-${index}`] }) - ) - } - - filters = setLinearWorkspaceIssueFilter(filters, '42', FILTER_A) - - expect(selectLinearWorkspaceIssueFilter(filters, '42')).toEqual(FILTER_A) - }) - it('returns the same record when the canonical filter is unchanged', () => { const filters = setLinearWorkspaceIssueFilter({}, 'workspace-a', FILTER_A) diff --git a/src/shared/linear-issue-view-resume-state.ts b/src/shared/linear-issue-view-resume-state.ts index 940618224cd..11fbb967570 100644 --- a/src/shared/linear-issue-view-resume-state.ts +++ b/src/shared/linear-issue-view-resume-state.ts @@ -1,7 +1,4 @@ -// Why: single source of truth for the Linear issue list's persisted view state. -// The renderer's in-memory state, TaskResumeState, and the strict `ui.set` RPC -// schema all derive from these catalogs, so none of the three can drift apart. -// Persisted values are untrusted input: normalize before use, never assume shape. +// Shared catalogs keep the renderer, resume state, and RPC schema aligned. import { boundLinearIssueAttributeFilter, @@ -17,7 +14,6 @@ import { 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 -/** Catalog order doubles as the canonical serialization order for display properties. */ export const LINEAR_DISPLAY_PROPERTIES = [ 'state', 'priority', @@ -36,21 +32,15 @@ export const DEFAULT_LINEAR_VIEW_MODE: LinearViewMode = 'list' export const DEFAULT_LINEAR_GROUP_BY: LinearGroupBy = 'none' export const DEFAULT_LINEAR_ORDER_BY: LinearOrderBy = 'priority' -/** Bounds the persisted map; a user works out of a handful of Linear workspaces. */ -export const LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES = 20 - export type LinearIssueViewResumeState = { viewMode: LinearViewMode groupBy: LinearGroupBy orderBy: LinearOrderBy displayProperties: LinearDisplayProperty[] - /** True once the user toggles the Team column, which disables the single-team auto-hide. */ teamPropertyTouched: boolean - /** Facet ids (states, labels, users) are Linear-workspace scoped, so filters are kept per workspace. */ filtersByWorkspaceId: Record } -/** Serialization input: the renderer holds display properties as a Set. */ export type LinearIssueViewSelection = Omit & { displayProperties: Iterable } @@ -74,16 +64,6 @@ export function defaultLinearIssueViewResumeState(): LinearIssueViewResumeState } } -/** Emits catalog order so toggling a property off and back on produces an identical payload. */ -export function orderLinearDisplayProperties( - selected: Iterable -): LinearDisplayProperty[] { - const chosen = new Set(selected) - return LINEAR_DISPLAY_PROPERTIES.filter((property) => chosen.has(property)) -} - -// Why: assigning `__proto__` on a plain object hits the inherited setter, so an -// untrusted key of that name must never reach the record being built. function isSafeWorkspaceKey(key: string): boolean { return ( key.length > 0 && @@ -97,7 +77,6 @@ function normalizeFiltersByWorkspaceId(value: unknown): Record = {} - const order: string[] = [] for (const [workspaceId, filter] of Object.entries(value)) { if (!isSafeWorkspaceKey(workspaceId)) { continue @@ -106,41 +85,16 @@ function normalizeFiltersByWorkspaceId(value: unknown): Record, - order: readonly string[] -): Record { - const kept = order.filter((workspaceId) => - Object.prototype.hasOwnProperty.call(filters, workspaceId) - ) - if (kept.length <= LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES) { - return filters - } - const next: Record = {} - for (const workspaceId of kept.slice(-LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES)) { - next[workspaceId] = filters[workspaceId] as LinearIssueAttributeFilter } return next } -/** Validates untrusted persisted state; returns undefined when nothing worth restoring survives. */ export function normalizeLinearIssueViewResumeState( value: unknown ): LinearIssueViewResumeState | undefined { @@ -157,7 +111,6 @@ export function normalizeLinearIssueViewResumeState( if (isMember(LINEAR_ORDER_BY_OPTIONS, value.orderBy)) { next.orderBy = value.orderBy } - // Why: an empty array is meaningful (every property hidden), so only a non-array falls back. const displayProperties: unknown = value.displayProperties if (Array.isArray(displayProperties)) { next.displayProperties = LINEAR_DISPLAY_PROPERTIES.filter((property) => @@ -186,44 +139,33 @@ export function isDefaultLinearIssueViewResumeState(view: LinearIssueViewResumeS ) } -/** Canonical persisted payload: catalog-ordered properties, canonical filters, empties omitted. */ export function serializeLinearIssueViewResumeState( view: LinearIssueViewSelection ): LinearIssueViewResumeState { const filtersByWorkspaceId: Record = {} - const order: string[] = [] for (const [workspaceId, filter] of Object.entries(view.filtersByWorkspaceId)) { if (!isSafeWorkspaceKey(workspaceId) || isEmptyLinearIssueAttributeFilter(filter)) { continue } - // Why: canonicalize dedupes but does not bound; only the throwing parser does, and - // an over-length facet list here is rejected by `ui.set` and drops the WHOLE - // taskResumeState for paired clients on every subsequent write. const bounded = boundLinearIssueAttributeFilter(canonicalizeLinearIssueAttributeFilter(filter)) - // Why: bounding drops over-length ids, so a filter that was non-empty above can - // become empty here — persisting it would break the "empties omitted" contract. if (isEmptyLinearIssueAttributeFilter(bounded)) { continue } filtersByWorkspaceId[workspaceId] = bounded - order.push(workspaceId) } + const selectedDisplayProperties = new Set(view.displayProperties) return { viewMode: view.viewMode, groupBy: view.groupBy, orderBy: view.orderBy, - displayProperties: orderLinearDisplayProperties(view.displayProperties), + displayProperties: LINEAR_DISPLAY_PROPERTIES.filter((property) => + selectedDisplayProperties.has(property) + ), teamPropertyTouched: view.teamPropertyTouched, - filtersByWorkspaceId: trimToMostRecentWorkspaces(filtersByWorkspaceId, order) + filtersByWorkspaceId } } -/** - * The active filter is derived from the selected workspace rather than reset on - * switch, so no effect ordering can leave workspace A's facets applied to B. - * A null id (Linear unresolved, disconnected, or the cross-workspace "all" view) - * yields an empty filter while leaving every persisted workspace filter intact. - */ export function selectLinearWorkspaceIssueFilter( filters: Record, workspaceId: string | null @@ -237,7 +179,6 @@ export function selectLinearWorkspaceIssueFilter( return filter ? canonicalizeLinearIssueAttributeFilter(filter) : emptyLinearIssueAttributeFilter() } -/** Returns the same reference when the workspace's canonical filter is unchanged. */ export function setLinearWorkspaceIssueFilter( filters: Record, workspaceId: string, @@ -257,10 +198,6 @@ export function setLinearWorkspaceIssueFilter( if (isEmptyLinearIssueAttributeFilter(filter)) { return next } - // Why: re-touching a workspace makes it the most recent, so it must be the last - // to be evicted — not the first, which reassigning in place would leave it. next[workspaceId] = canonicalizeLinearIssueAttributeFilter(filter) - const order = Object.keys(filters).filter((key) => key !== workspaceId) - order.push(workspaceId) - return trimToMostRecentWorkspaces(next, order) + return next }