From 7965ccb222c89aa352c32371eefe501154dc93e3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:25:49 -0400 Subject: [PATCH] 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 --- .../web-jira-filter-state-api.test.ts | 143 ++++++++++++++++++ .../preload-api/web-jira-filter-state-api.ts | 70 +++++++++ .../src/web/preload-api/web-ui-api.ts | 2 + 3 files changed, 215 insertions(+) create mode 100644 src/renderer/src/web/preload-api/web-jira-filter-state-api.test.ts create mode 100644 src/renderer/src/web/preload-api/web-jira-filter-state-api.ts diff --git a/src/renderer/src/web/preload-api/web-jira-filter-state-api.test.ts b/src/renderer/src/web/preload-api/web-jira-filter-state-api.test.ts new file mode 100644 index 00000000000..161c7c77870 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-jira-filter-state-api.test.ts @@ -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() +}) diff --git a/src/renderer/src/web/preload-api/web-jira-filter-state-api.ts b/src/renderer/src/web/preload-api/web-jira-filter-state-api.ts new file mode 100644 index 00000000000..a4d1f49b67c --- /dev/null +++ b/src/renderer/src/web/preload-api/web-jira-filter-state-api.ts @@ -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['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( + capability: RuntimeCapability, + updateRequired: string, + method: string, + params?: unknown +): Promise { + 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(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( + JIRA_STATUS_FILTER_RUNTIME_CAPABILITY, + JIRA_STATUS_FILTER_UPDATE_REQUIRED, + 'ui.getJiraQuickFilters' + ), + setJiraQuickFilters: (state) => + callJiraFilterMethod( + JIRA_STATUS_FILTER_RUNTIME_CAPABILITY, + JIRA_STATUS_FILTER_UPDATE_REQUIRED, + 'ui.setJiraQuickFilters', + state + ), + getJiraSavedFilters: () => + callJiraFilterMethod( + JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, + JIRA_SAVED_FILTER_UPDATE_REQUIRED, + 'ui.getJiraSavedFilters' + ), + mutateJiraSavedFilters: (mutation) => + callJiraFilterMethod( + JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, + JIRA_SAVED_FILTER_UPDATE_REQUIRED, + 'ui.mutateJiraSavedFilters', + mutation + ) + } +} diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 993e15d5a24..0d926726009 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -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['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 {