mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Move Linear issue view to localStorage (#13391)
Prevent older paired hosts from discarding the entire taskResumeState
when they encounter the strict linearIssueView field, which was causing
silent loss of github and jira queries during remote pairing. Layout,
grouping, ordering, and per-workspace filters are now device-local.
(cherry picked from commit d23dda6d48)
This commit is contained in:
@@ -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<typeof vi.fn> } {
|
||||
const updateUIState = vi.fn(() => getDefaultUIState())
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateUIState
|
||||
} as unknown as OrcaRuntimeService
|
||||
return { dispatcher: new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }), updateUIState }
|
||||
}
|
||||
|
||||
const VALID_VIEW = {
|
||||
viewMode: 'list',
|
||||
groupBy: 'none',
|
||||
orderBy: 'priority',
|
||||
displayProperties: [],
|
||||
teamPropertyTouched: false,
|
||||
filtersByWorkspaceId: {}
|
||||
}
|
||||
|
||||
describe('ui.set Linear issue view resume state', () => {
|
||||
// Why: the schema is strict, so a field the renderer persists but the schema
|
||||
// omits drops the whole taskResumeState for paired web/mobile/relay clients.
|
||||
it.each([
|
||||
[
|
||||
'a fully populated view',
|
||||
{
|
||||
viewMode: 'board',
|
||||
groupBy: 'assignee',
|
||||
orderBy: 'updated',
|
||||
displayProperties: ['state', 'labels'],
|
||||
teamPropertyTouched: true,
|
||||
filtersByWorkspaceId: {
|
||||
'workspace-1': {
|
||||
stateIds: ['state-1'],
|
||||
priorities: [2],
|
||||
assignee: { kind: 'user', id: 'user-1' },
|
||||
labelIds: ['label-1']
|
||||
},
|
||||
'workspace-2': {
|
||||
stateIds: [],
|
||||
priorities: [],
|
||||
assignee: { kind: 'unassigned' },
|
||||
labelIds: ['label-2']
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
// Why: an empty display-property array means every column is hidden, not "unset".
|
||||
['every column hidden and no filters', VALID_VIEW]
|
||||
])('accepts %s', async (_label, linearIssueView) => {
|
||||
const { dispatcher, updateUIState } = makeDispatcher()
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({ taskResumeState: { linearIssueView } })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(updateUIState).toHaveBeenCalledWith({ taskResumeState: { linearIssueView } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an unlisted key', { ...VALID_VIEW, linearIssueFilterWorkspaceId: 'workspace-1' }],
|
||||
['an unknown view mode', { ...VALID_VIEW, viewMode: 'grid' }],
|
||||
['an unknown display property', { ...VALID_VIEW, displayProperties: ['state', 'bogus'] }],
|
||||
['a missing required field', { viewMode: 'board', groupBy: 'none' }],
|
||||
['a non-object filter', { ...VALID_VIEW, filtersByWorkspaceId: { 'workspace-1': 'all' } }],
|
||||
[
|
||||
'an out-of-range priority',
|
||||
{
|
||||
...VALID_VIEW,
|
||||
filtersByWorkspaceId: {
|
||||
'workspace-1': { stateIds: [], priorities: [9], assignee: null, labelIds: [] }
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'an over-long facet id',
|
||||
{
|
||||
...VALID_VIEW,
|
||||
filtersByWorkspaceId: {
|
||||
'workspace-1': {
|
||||
stateIds: ['x'.repeat(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH + 1)],
|
||||
priorities: [],
|
||||
assignee: null,
|
||||
labelIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'an over-long workspace key',
|
||||
{
|
||||
...VALID_VIEW,
|
||||
filtersByWorkspaceId: {
|
||||
['w'.repeat(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH + 1)]: {
|
||||
stateIds: ['state-1'],
|
||||
priorities: [],
|
||||
assignee: null,
|
||||
labelIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
])('drops only the view when it has %s', async (_label, linearIssueView) => {
|
||||
const { dispatcher, updateUIState } = makeDispatcher()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({
|
||||
sidebarWidth: 280,
|
||||
taskResumeState: { linearQuery: 'label:bug', jiraPreset: 'reported', linearIssueView }
|
||||
})
|
||||
)
|
||||
|
||||
// Why: value tolerance stops at the top level, so without the schema's own `.catch`
|
||||
// one bad view value would drop every other resume field too — and keep doing so on
|
||||
// every subsequent write, since the renderer resends the whole merged object.
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(updateUIState).toHaveBeenCalledWith({
|
||||
sidebarWidth: 280,
|
||||
taskResumeState: {
|
||||
linearQuery: 'label:bug',
|
||||
jiraPreset: 'reported',
|
||||
linearIssueView: undefined
|
||||
}
|
||||
})
|
||||
// Why: deep equality treats an absent key and an explicit `undefined` as equal, but
|
||||
// a merging store does not — an explicit `undefined` is what clears the stored view.
|
||||
const [payload] = updateUIState.mock.calls[0] as [{ taskResumeState: object }]
|
||||
expect('linearIssueView' in payload.taskResumeState).toBe(true)
|
||||
// Why: the discard is otherwise invisible; the log is the only trace of the drift.
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('linearIssueView'),
|
||||
expect.arrayContaining([expect.objectContaining({ code: expect.any(String) })])
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
// Why: hand-written fixtures can't catch renderer/schema drift — only the renderer's
|
||||
// own output can. An over-limit filter here stops ALL resume state from persisting.
|
||||
it.each([
|
||||
[
|
||||
'a filter at the facet-count limit',
|
||||
Array.from(
|
||||
{ length: LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS + 1 },
|
||||
(_, index) => `label-${String(index).padStart(4, '0')}`
|
||||
)
|
||||
],
|
||||
[
|
||||
'a filter with an over-long facet id',
|
||||
['x'.repeat(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH + 1)]
|
||||
]
|
||||
])('accepts serializer output for %s', async (_label, labelIds) => {
|
||||
const { dispatcher, updateUIState } = makeDispatcher()
|
||||
const linearIssueView = serializeLinearIssueViewResumeState({
|
||||
...defaultLinearIssueViewResumeState(),
|
||||
filtersByWorkspaceId: {
|
||||
'workspace-1': { stateIds: [], priorities: [], assignee: null, labelIds }
|
||||
}
|
||||
})
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({ taskResumeState: { linearIssueView } })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(updateUIState).toHaveBeenCalledWith({ taskResumeState: { linearIssueView } })
|
||||
})
|
||||
})
|
||||
@@ -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<typeof vi.fn> } {
|
||||
const updateUIState = vi.fn(() => getDefaultUIState())
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateUIState
|
||||
} as unknown as OrcaRuntimeService
|
||||
return { dispatcher: new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }), updateUIState }
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<typeof TaskResumeState>
|
||||
> = true
|
||||
void _taskResumeStateParity
|
||||
|
||||
// Why: the top-level assertion only compares TaskResumeState's own keys, so a field
|
||||
// added one level down stays invisible to it — and `.strict()` rejects exactly that.
|
||||
const _linearIssueViewParity: AssertNoMissingKeys<
|
||||
NonNullable<TaskResumeStateType['linearIssueView']>,
|
||||
z.infer<typeof LinearIssueViewResumeState>
|
||||
> = true
|
||||
void _linearIssueViewParity
|
||||
|
||||
// Why: `.strict()` only rejects keys the SCHEMA omits. A key the schema gains and the
|
||||
// shared type lacks passes validation and reaches the renderer unmodelled instead.
|
||||
const _linearIssueViewExtraKeys: AssertNoExtraKeys<
|
||||
NonNullable<TaskResumeStateType['linearIssueView']>,
|
||||
z.infer<typeof LinearIssueViewResumeState>
|
||||
> = true
|
||||
void _linearIssueViewExtraKeys
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, unknown>
|
||||
if (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user