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 deleted file mode 100644 index 3403feb58e3..00000000000 --- a/src/main/runtime/rpc/methods/client-ui-linear-issue-view.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -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/client-ui-task-resume-state.test.ts b/src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts new file mode 100644 index 00000000000..909975923ae --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultUIState } from '../../../../shared/constants' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { CLIENT_UI_METHODS } from './client-ui' +import { TaskResumeState } from './task-resume-state-schema' + +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 } +} + +describe('ui.set task resume state', () => { + it('persists the cross-client resume fields', async () => { + const { dispatcher, updateUIState } = makeDispatcher() + const taskResumeState = { + githubMode: 'items' as const, + githubItemsQuery: 'is:open', + linearMode: 'issues' as const, + linearQuery: 'label:bug', + jiraPreset: 'reported' as const + } + + const response = await dispatcher.dispatch(makeRequest({ taskResumeState })) + + expect(response).toMatchObject({ ok: true }) + expect(updateUIState).toHaveBeenCalledWith({ taskResumeState }) + }) + + // Why: this object is `.strict()` behind ui.set's field-level `.catch`, so ONE key a + // host predates silently discards the whole resume state — github and jira with it — + // and still reports success. That is why per-device view preferences stay client-local. + it('drops the whole resume state when a payload carries a key the schema omits', async () => { + const { dispatcher, updateUIState } = makeDispatcher() + + const response = await dispatcher.dispatch( + makeRequest({ + sidebarWidth: 280, + taskResumeState: { linearQuery: 'label:bug', somethingThisHostPredates: true } + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(updateUIState).toHaveBeenCalledWith({ sidebarWidth: 280 }) + }) + + // Why: #12710 shipped this key and every host older than it drops the whole resume + // state on receipt. It is device-local storage now; putting it back re-breaks pairing. + it('keeps the Linear issue view off the wire', () => { + expect(Object.keys(TaskResumeState.shape)).not.toContain('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 e7bbca74a80..06e90f8a811 100644 --- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -1,46 +1,15 @@ 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 { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema' -import type { AssertNoExtraKeys, AssertNoMissingKeys } from './ui-state-schema-parity' +import type { 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`. */ +/** + * Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. + * + * This object is `.strict()` and sits behind `ui.set`'s field-level `.catch`, so a key + * a host predates makes that host drop the ENTIRE resume state — github and jira with + * it — and report success. Only add a field here when clients must agree on it across + * versions; per-device view preferences belong in client-local storage instead. + */ export const TaskResumeState = z .object({ githubMode: z.enum(['items', 'project']).optional(), @@ -59,10 +28,6 @@ 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() }) @@ -73,19 +38,3 @@ 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/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 8358490a9ba..85efffe1afb 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -287,11 +287,11 @@ import { DEFAULT_LINEAR_ORDER_BY, DEFAULT_LINEAR_VIEW_MODE, LINEAR_DISPLAY_PROPERTIES, - resolveLinearIssueViewResumeState, selectLinearWorkspaceIssueFilter, serializeLinearIssueViewResumeState, setLinearWorkspaceIssueFilter } from '../../../shared/linear-issue-view-resume-state' +import { loadLinearIssueView, saveLinearIssueView } from './linear-issue-view-storage' import { isNewIssueDraftContentful, resolveNewIssueOpenSeed, @@ -4754,7 +4754,7 @@ export default function TaskPage(): React.JSX.Element { setLinearSearchInput(linearQuery) setAppliedLinearSearch(linearQuery) - const linearIssueView = resolveLinearIssueViewResumeState(taskResumeState?.linearIssueView) + const linearIssueView = loadLinearIssueView() setLinearViewMode(linearIssueView.viewMode) setLinearGroupBy(linearIssueView.groupBy) setLinearOrderBy(linearIssueView.orderBy) @@ -7935,8 +7935,8 @@ export default function TaskPage(): React.JSX.Element { linearViewPersistReadyRef.current = true return } - setTaskResumeState({ - linearIssueView: serializeLinearIssueViewResumeState({ + saveLinearIssueView( + serializeLinearIssueViewResumeState({ viewMode: linearViewMode, groupBy: linearGroupBy, orderBy: linearOrderBy, @@ -7944,7 +7944,7 @@ export default function TaskPage(): React.JSX.Element { teamPropertyTouched: linearTeamPropertyTouched, filtersByWorkspaceId: linearIssueFiltersByWorkspaceId }) - }) + ) }, [ linearDisplayProperties, linearGroupBy, @@ -7952,7 +7952,6 @@ export default function TaskPage(): React.JSX.Element { linearOrderBy, linearTeamPropertyTouched, linearViewMode, - setTaskResumeState, taskResumeApplied ]) diff --git a/src/renderer/src/components/linear-issue-view-storage.test.ts b/src/renderer/src/components/linear-issue-view-storage.test.ts new file mode 100644 index 00000000000..b9654721ba8 --- /dev/null +++ b/src/renderer/src/components/linear-issue-view-storage.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest' +import { defaultLinearIssueViewResumeState } from '../../../shared/linear-issue-view-resume-state' +import { loadLinearIssueView, saveLinearIssueView } from './linear-issue-view-storage' + +const STORAGE_KEY = 'orca.linear.issue-view.v1' + +describe('Linear issue view local storage', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('falls back to defaults when nothing is stored', () => { + expect(loadLinearIssueView()).toEqual(defaultLinearIssueViewResumeState()) + }) + + it('round-trips a non-default view', () => { + const view = { + ...defaultLinearIssueViewResumeState(), + viewMode: 'board' as const, + groupBy: 'assignee' as const, + filtersByWorkspaceId: { + 'workspace-1': { stateIds: ['state-a'], priorities: [2], assignee: null, labelIds: [] } + } + } + + saveLinearIssueView(view) + + expect(loadLinearIssueView()).toEqual(view) + }) + + it('clears the key when the view returns to defaults', () => { + saveLinearIssueView({ ...defaultLinearIssueViewResumeState(), viewMode: 'board' }) + + saveLinearIssueView(defaultLinearIssueViewResumeState()) + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + expect(loadLinearIssueView()).toEqual(defaultLinearIssueViewResumeState()) + }) + + it('falls back to defaults when the stored value is unparseable', () => { + localStorage.setItem(STORAGE_KEY, '{not json') + + expect(loadLinearIssueView()).toEqual(defaultLinearIssueViewResumeState()) + }) + + it('drops a corrupt workspace filter without losing the other workspaces', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + viewMode: 'board', + filtersByWorkspaceId: { + 'workspace-1': { stateIds: 'not-an-array' }, + 'workspace-2': { stateIds: [], priorities: [1], assignee: null, labelIds: [] } + } + }) + ) + + expect(loadLinearIssueView().filtersByWorkspaceId).toEqual({ + 'workspace-2': { stateIds: [], priorities: [1], assignee: null, labelIds: [] } + }) + }) + + it('normalizes an out-of-catalog option instead of discarding the whole view', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ viewMode: 'board', groupBy: 'nonsense', orderBy: 'updated' }) + ) + + const view = loadLinearIssueView() + + expect(view.viewMode).toBe('board') + expect(view.groupBy).toBe('none') + expect(view.orderBy).toBe('updated') + }) +}) diff --git a/src/renderer/src/components/linear-issue-view-storage.ts b/src/renderer/src/components/linear-issue-view-storage.ts new file mode 100644 index 00000000000..b34f0cc55fa --- /dev/null +++ b/src/renderer/src/components/linear-issue-view-storage.ts @@ -0,0 +1,34 @@ +import { + normalizeLinearIssueViewResumeState, + resolveLinearIssueViewResumeState, + type LinearIssueViewResumeState +} from '../../../shared/linear-issue-view-resume-state' + +// Why: issue-list layout is a per-device view preference, not host state. Routing it +// through `ui.set` put it inside a strict nested schema, where a host that predates +// the field silently discarded the WHOLE taskResumeState — github and jira included. +const STORAGE_KEY = 'orca.linear.issue-view.v1' + +export function loadLinearIssueView(): LinearIssueViewResumeState { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return resolveLinearIssueViewResumeState(raw ? (JSON.parse(raw) as unknown) : undefined) + } catch { + return resolveLinearIssueViewResumeState(undefined) + } +} + +export function saveLinearIssueView(view: LinearIssueViewResumeState): void { + try { + // Normalization returns undefined for an all-defaults view, so resetting the + // list clears the key instead of pinning today's defaults against a future change. + const normalized = normalizeLinearIssueViewResumeState(view) + if (!normalized) { + localStorage.removeItem(STORAGE_KEY) + return + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)) + } catch { + // The live view stays usable when browser storage is unavailable or full. + } +} diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index fc12b099238..b8bccc235a4 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1665,21 +1665,6 @@ 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'] @@ -1690,33 +1675,20 @@ 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', () => { + // Why: the Linear issue view is device-local now. A host that still holds one from + // an older build must not reintroduce the key the strict ui.set schema rejects. + it('ignores a stale host-persisted Linear issue view during hydration', () => { const store = createUIStore() store.getState().hydratePersistedUI( makePersistedUI({ taskResumeState: { linearQuery: 'label:bug', - linearIssueView: 'board' + linearIssueView: { viewMode: 'board', groupBy: 'assignee' } } as unknown as PersistedUIState['taskResumeState'] }) ) @@ -1724,32 +1696,6 @@ describe('createUISlice hydratePersistedUI', () => { 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 908310a56b2..a00e932c755 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -36,7 +36,6 @@ 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 { @@ -560,12 +559,6 @@ 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/types.ts b/src/shared/types.ts index 54eab883469..a67ce754383 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ import type { ExecutionHostId } from './execution-host' -import type { LinearIssueViewResumeState } from './linear-issue-view-resume-state' import type { RemovedSshTargetTombstone, SshPtyConsumerRecovery, @@ -3342,8 +3341,6 @@ 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 index ce892543bba..2993895ade4 100644 --- a/tests/e2e/linear-issue-view-persistence.spec.ts +++ b/tests/e2e/linear-issue-view-persistence.spec.ts @@ -1,10 +1,11 @@ /** * 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. + * Layout (list/board), grouping, ordering, and per-workspace attribute filters are + * a per-device preference: they persist to renderer localStorage, NOT through + * `ui.set`, so a paired host that predates the field cannot discard the resume + * state around it. 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' @@ -14,6 +15,10 @@ import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca- import { getStoreState, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { TEST_REPO_PATH_FILE } from './global-setup' +// Mirrors src/renderer/src/components/linear-issue-view-storage.ts; hardcoded so a +// silent key rename shows up here as a failing round-trip. +const LINEAR_ISSUE_VIEW_STORAGE_KEY = 'orca.linear.issue-view.v1' + const WORKSPACE_A = { id: 'linear-workspace-a', displayName: 'Linear E2E User A', @@ -331,15 +336,16 @@ async function waitForLinearIssueViewPersisted( await expect .poll( async () => { - const resume = await getStoreState<{ linearIssueView?: LinearIssueViewResume } | undefined>( - page, - 'taskResumeState' + const stored = await page.evaluate( + (key) => window.localStorage.getItem(key), + LINEAR_ISSUE_VIEW_STORAGE_KEY ) - return predicate(resume?.linearIssueView) ? 'ready' : 'pending' + const view = stored ? (JSON.parse(stored) as LinearIssueViewResume) : undefined + return predicate(view) ? 'ready' : 'pending' }, { timeout: 10_000, - message: 'Linear issue view preferences did not persist to taskResumeState' + message: 'Linear issue view preferences did not persist to local storage' } ) .toBe('ready')