fix(web): persist Jira saved and status filters on the paired host (#29203)

The browser client's UI bridge lacked the four Jira filter methods, so the
fallback bridge resolved undefined and filters showed empty and never saved.
Route them to the active host, gated on its capability so older hosts report
that an update is required.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-15 16:25:49 -04:00
committed by GitHub
co-authored by Claude
parent 06ead8dad0
commit 7965ccb222
3 changed files with 215 additions and 0 deletions
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
JIRA_SAVED_FILTER_UPDATE_REQUIRED
} from '../../../../shared/jira-saved-filter-capability'
import {
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED
} from '../../../../shared/jira-quick-filters'
import {
mutateJiraSavedFilters,
readJiraSavedFilterState
} from '../../runtime/runtime-jira-saved-filter-state'
import {
readJiraQuickFilterState,
writeJiraQuickFilterState
} from '../../runtime/runtime-jira-quick-filter-state'
import { createWebUiApi } from './web-ui-api'
const runtime = vi.hoisted(() => {
const state: { activeId: string; idAfterProbe: string | null } = {
activeId: 'host-a',
idAfterProbe: null
}
return Object.assign(state, { call: vi.fn(), status: vi.fn() })
})
vi.mock('./web-runtime-calls', () => ({
callRuntimeResult: runtime.call,
getRemoteRuntimeStatus: async () => {
const status = await runtime.status()
if (runtime.idAfterProbe) {
runtime.activeId = runtime.idAfterProbe
}
return status
}
}))
vi.mock('./web-runtime-session', () => ({
webRuntimeState: { activeEnvironment: null },
requireActiveEnvironmentOrNull: () => ({ id: runtime.activeId }),
requireActiveEnvironment: () => ({ id: runtime.activeId }),
assertActiveEnvironment: (id: string) => {
if (id !== runtime.activeId) {
throw new Error('Pairing changed')
}
}
}))
const rpc = vi.hoisted(() => vi.fn())
vi.mock('../../runtime/runtime-rpc-client', () => ({
assertRuntimeEnvironmentCapability: vi.fn(),
callRuntimeRpc: rpc
}))
const savedState = {
filters: [{ id: 'mine', name: 'My work', jql: 'project = APP' }],
activeFilterId: 'mine'
}
const quickState = { statuses: ['In Progress'] }
const BOTH = [JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, JIRA_STATUS_FILTER_RUNTIME_CAPABILITY]
beforeEach(() => {
localStorage.clear()
runtime.call.mockReset()
runtime.status.mockReset()
runtime.activeId = 'host-a'
runtime.idAfterProbe = null
rpc.mockReset()
vi.stubGlobal('window', { localStorage, api: { ui: createWebUiApi() } })
})
afterEach(() => vi.unstubAllGlobals())
it('reads and writes Jira filter state on the paired host from the local target', async () => {
runtime.status.mockResolvedValue({ capabilities: BOTH })
runtime.call.mockImplementation(async (method: string) =>
method.includes('Saved') ? savedState : quickState
)
expect(await readJiraSavedFilterState({ kind: 'local' })).toEqual(savedState)
await mutateJiraSavedFilters({ kind: 'local' }, { kind: 'select', id: 'mine' })
await readJiraQuickFilterState({ kind: 'local' })
await writeJiraQuickFilterState({ kind: 'local' }, quickState)
expect(runtime.call.mock.calls).toEqual([
['ui.getJiraSavedFilters', undefined, 15_000],
['ui.mutateJiraSavedFilters', { kind: 'select', id: 'mine' }, 15_000],
['ui.getJiraQuickFilters', undefined, 15_000],
['ui.setJiraQuickFilters', quickState, 15_000]
])
expect(rpc).not.toHaveBeenCalled()
})
it('reports update-required for an older host and never sends the new methods', async () => {
runtime.status.mockResolvedValue({ capabilities: [] })
await expect(readJiraSavedFilterState({ kind: 'local' })).rejects.toThrow(
JIRA_SAVED_FILTER_UPDATE_REQUIRED
)
await expect(
mutateJiraSavedFilters({ kind: 'local' }, { kind: 'select', id: null })
).rejects.toThrow(JIRA_SAVED_FILTER_UPDATE_REQUIRED)
await expect(readJiraQuickFilterState({ kind: 'local' })).rejects.toThrow(
JIRA_STATUS_FILTER_UPDATE_REQUIRED
)
await expect(writeJiraQuickFilterState({ kind: 'local' }, quickState)).rejects.toThrow(
JIRA_STATUS_FILTER_UPDATE_REQUIRED
)
expect(runtime.call).not.toHaveBeenCalled()
})
it('rejects host failures instead of returning empty or unsaved state', async () => {
runtime.status.mockRejectedValueOnce(new Error('Connection lost'))
await expect(readJiraSavedFilterState({ kind: 'local' })).rejects.toThrow('Connection lost')
runtime.status.mockResolvedValue({ capabilities: BOTH })
runtime.call.mockRejectedValue(new Error('Host write failed'))
await expect(writeJiraQuickFilterState({ kind: 'local' }, quickState)).rejects.toThrow(
'Host write failed'
)
})
it('does not send to a host paired in while the capability probe was in flight', async () => {
runtime.status.mockResolvedValue({ capabilities: BOTH })
runtime.idAfterProbe = 'host-b'
await expect(
mutateJiraSavedFilters({ kind: 'local' }, { kind: 'select', id: 'mine' })
).rejects.toThrow('Pairing changed')
expect(runtime.call).not.toHaveBeenCalled()
})
it('keeps explicit environment targets on the environment RPC path', async () => {
rpc.mockResolvedValue(savedState)
await readJiraSavedFilterState({ kind: 'environment', environmentId: 'host-c' })
expect(rpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'host-c' },
'ui.getJiraSavedFilters',
undefined,
{ timeoutMs: 15_000 }
)
expect(runtime.call).not.toHaveBeenCalled()
})
@@ -0,0 +1,70 @@
import type { PreloadApi } from '../../../../preload/api-types'
import {
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
JIRA_SAVED_FILTER_UPDATE_REQUIRED
} from '../../../../shared/jira-saved-filter-capability'
import {
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
type JiraQuickFilterState
} from '../../../../shared/jira-quick-filters'
import type { RuntimeCapability } from '../../../../shared/protocol-version'
import type { JiraSavedFilterState } from '../../../../shared/jira-saved-filters'
import { callRuntimeResult, getRemoteRuntimeStatus } from './web-runtime-calls'
import { assertActiveEnvironment, requireActiveEnvironment } from './web-runtime-session'
type WebJiraFilterStateApi = Pick<
NonNullable<Partial<PreloadApi>['ui']>,
'getJiraQuickFilters' | 'setJiraQuickFilters' | 'getJiraSavedFilters' | 'mutateJiraSavedFilters'
>
const JIRA_FILTER_TIMEOUT_MS = 15_000
/** Calls the active host only when it advertises the method, so an older host reports "update required" instead of failing generically. */
async function callJiraFilterMethod<TResult>(
capability: RuntimeCapability,
updateRequired: string,
method: string,
params?: unknown
): Promise<TResult> {
const environmentId = requireActiveEnvironment().id
const status = await getRemoteRuntimeStatus()
// Why: the capability verdict belongs to the probed host; never send to a host switched in mid-probe.
assertActiveEnvironment(environmentId)
if (!status.capabilities?.includes(capability)) {
throw new Error(updateRequired)
}
return callRuntimeResult<TResult>(method, params, JIRA_FILTER_TIMEOUT_MS)
}
/** Jira filter state lives on the paired host, which is the web client's local execution target. */
export function createWebJiraFilterStateApi(): WebJiraFilterStateApi {
return {
getJiraQuickFilters: () =>
callJiraFilterMethod<JiraQuickFilterState>(
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
'ui.getJiraQuickFilters'
),
setJiraQuickFilters: (state) =>
callJiraFilterMethod<JiraQuickFilterState>(
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
'ui.setJiraQuickFilters',
state
),
getJiraSavedFilters: () =>
callJiraFilterMethod<JiraSavedFilterState>(
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
JIRA_SAVED_FILTER_UPDATE_REQUIRED,
'ui.getJiraSavedFilters'
),
mutateJiraSavedFilters: (mutation) =>
callJiraFilterMethod<JiraSavedFilterState>(
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
JIRA_SAVED_FILTER_UPDATE_REQUIRED,
'ui.mutateJiraSavedFilters',
mutation
)
}
}
@@ -1,6 +1,7 @@
import type { AgentSessionAttachmentClipboardTarget } from '../../../../shared/agent-session-attachments'
import { saveClipboardImageAsWebAgentSessionAttachment } from './web-agent-session-attachment-upload'
import { createWebExplorerRootSync } from './web-explorer-root-sync'
import { createWebJiraFilterStateApi } from './web-jira-filter-state-api'
import type { PreloadApi } from '../../../../preload/api-types'
import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text'
import type { ReadClipboardTextOptions } from '../../../../shared/clipboard-text'
@@ -39,6 +40,7 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
}
let zoomLevel = readLocalWebUIState().uiZoomLevel
return {
...createWebJiraFilterStateApi(),
/** Hydrates from the active host after replaying pending roots, falling back to local state on failure or host changes. */
get: async () => {
try {