diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts
index 6d0db96c1a2..581262793cb 100644
--- a/src/main/ipc/jira.ts
+++ b/src/main/ipc/jira.ts
@@ -1,3 +1,4 @@
+import { JiraIssueListFiltersParam } from '../../shared/rpc-contract/jira-quick-filter-params'
import { ipcMain } from 'electron'
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
import { _resetPreflightCache } from './preflight'
@@ -23,6 +24,7 @@ import type {
JiraConnectArgs,
JiraCreateIssueArgs,
JiraIssueFilter,
+ JiraIssueListFilters,
JiraIssueUpdate,
JiraSiteSelection
} from '../../shared/jira-types'
@@ -154,12 +156,23 @@ export function registerJiraHandlers(): void {
'jira:listIssues',
async (
_event,
- args?: { filter?: JiraIssueFilter; limit?: number; siteId?: JiraSiteSelection }
+ args?: {
+ filter?: JiraIssueFilter
+ limit?: number
+ siteId?: JiraSiteSelection
+ filters?: JiraIssueListFilters
+ }
) => {
const filter = VALID_FILTERS.has(args?.filter as JiraIssueFilter)
? (args!.filter as JiraIssueFilter)
: undefined
- return listIssues(filter, clampLimit(args?.limit), normalizeSiteSelection(args?.siteId))
+ return listIssues(
+ filter,
+ clampLimit(args?.limit),
+ normalizeSiteSelection(args?.siteId),
+ false,
+ args?.filters ? JiraIssueListFiltersParam.parse(args.filters) : undefined
+ )
}
)
diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts
index cc76aaada59..93c2150e71e 100644
--- a/src/main/ipc/ui.ts
+++ b/src/main/ipc/ui.ts
@@ -1,3 +1,5 @@
+import { normalizeJiraQuickFilterState } from '../../shared/jira-quick-filters'
+import { JiraQuickFilterStateParam } from '../../shared/rpc-contract/jira-quick-filter-params'
import {
normalizeJiraSavedFilterState,
mutateJiraSavedFilterState
@@ -64,6 +66,14 @@ export function registerUIHandlers(
}
})
+ ipcMain.handle('ui:getJiraQuickFilters', () =>
+ normalizeJiraQuickFilterState(store.getUI().jiraQuickFilterState)
+ )
+ ipcMain.handle('ui:setJiraQuickFilters', (_event, input: unknown) => {
+ const state = normalizeJiraQuickFilterState(JiraQuickFilterStateParam.parse(input))
+ store.updateUI({ jiraQuickFilterState: state })
+ return state
+ })
ipcMain.handle('ui:getJiraSavedFilters', () =>
normalizeJiraSavedFilterState(store.getUI().jiraSavedFilterState)
)
diff --git a/src/main/jira/jira-issue-search.ts b/src/main/jira/jira-issue-search.ts
index d817e2abaaf..59f7d87675b 100644
--- a/src/main/jira/jira-issue-search.ts
+++ b/src/main/jira/jira-issue-search.ts
@@ -1,5 +1,10 @@
import { sortByUpdatedAtDescending } from '../../shared/updated-at-order'
-import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types'
+import type {
+ JiraIssue,
+ JiraIssueFilter,
+ JiraIssueListFilters,
+ JiraSiteSelection
+} from '../../shared/jira-types'
import { buildJiraPresetJql } from '../../shared/jira-preset-jql'
import { acquire, release } from './request-queue'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
@@ -18,7 +23,7 @@ import {
const ISSUE_SEARCH_TIMEOUT_MS = 30_000
const PROJECTS_PER_QUERY = 100
-type JiraIssueQuery = { filter: JiraIssueFilter } | { jql: string }
+type JiraIssueQuery = { filter: JiraIssueFilter; filters?: JiraIssueListFilters } | { jql: string }
function clampLimit(limit: number | undefined, fallback = 30): number {
return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100)
@@ -38,7 +43,7 @@ async function readIssuesForClient(
if ('jql' in query) {
return searchIssuesForClient(entry, query.jql, limit, agileFields, signal)
}
- const jql = buildJiraPresetJql(query.filter)
+ const jql = buildJiraPresetJql(query.filter, query.filters)
if (query.filter !== 'all' || entry.site.authType === 'server') {
return searchIssuesForClient(entry, jql, limit, agileFields, signal)
}
@@ -122,9 +127,10 @@ export async function listIssues(
filter: JiraIssueFilter = 'assigned',
limit = 30,
siteId?: JiraSiteSelection | null,
- strict = false
+ strict = false,
+ filters?: JiraIssueListFilters
): Promise {
- return runIssueSearch({ filter }, limit, siteId, undefined, strict)
+ return runIssueSearch({ filter, filters }, limit, siteId, undefined, strict)
}
export async function searchIssues(
diff --git a/src/main/jira/jira-preset-filters.test.ts b/src/main/jira/jira-preset-filters.test.ts
index 648c19cee9d..c6a46491c19 100644
--- a/src/main/jira/jira-preset-filters.test.ts
+++ b/src/main/jira/jira-preset-filters.test.ts
@@ -121,6 +121,23 @@ describe('Jira preset filters', () => {
expect(jiraRequest).toHaveBeenCalledTimes(3)
})
+ it('composes multi-status filters inside the bounded Cloud All query', async () => {
+ jiraRequest.mockImplementation(async (_entry, path, init) => {
+ if (path.includes('/project/search')) {
+ return { isLast: true, values: [{ id: '10', key: 'APP' }] }
+ }
+ expect(requestJql(init)).toBe(
+ 'project in ("10") AND statusCategory != Done AND status in ("Blocked", "In Progress") ORDER BY updated DESC'
+ )
+ return { issues: [issue('APP-1', false, false)] }
+ })
+ const { listIssues } = await import('./jira-issue-search')
+ await expect(
+ listIssues('all', 20, 'cloud', true, { statuses: ['Blocked', 'In Progress'] })
+ ).resolves.toMatchObject([{ key: 'APP-1' }])
+ expect(jiraRequest).toHaveBeenCalledTimes(2)
+ })
+
it('keeps Server/DC All on classic search without project discovery', async () => {
getClients.mockReturnValue([site('server', 'server')])
jiraRequest.mockResolvedValue({ issues: [] })
diff --git a/src/main/persistence-jira-saved-filters.test.ts b/src/main/persistence-jira-saved-filters.test.ts
index 565621de629..3271c1f4f06 100644
--- a/src/main/persistence-jira-saved-filters.test.ts
+++ b/src/main/persistence-jira-saved-filters.test.ts
@@ -22,9 +22,13 @@ it('persists multiple saved filters and the active selection across a profile re
activeFilterId: 'bugs'
}
const store = createStore()
- store.updateUI({ jiraSavedFilterState: state })
+ store.updateUI({
+ jiraSavedFilterState: state,
+ jiraQuickFilterState: { statuses: ['In Progress', 'Blocked'] }
+ })
store.flush()
await closeTestStores()
const reloaded = createStore()
expect(normalizeJiraSavedFilterState(reloaded.getUI().jiraSavedFilterState)).toEqual(state)
+ expect(reloaded.getUI().jiraQuickFilterState).toEqual({ statuses: ['In Progress', 'Blocked'] })
})
diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts
index c64749c3d75..23a7eb3e067 100644
--- a/src/main/runtime/rpc/methods/client-ui.ts
+++ b/src/main/runtime/rpc/methods/client-ui.ts
@@ -1,3 +1,5 @@
+import { normalizeJiraQuickFilterState } from '../../../../shared/jira-quick-filters'
+import { JiraQuickFilterStateParam } from '../../../../shared/rpc-contract/jira-quick-filter-params'
import {
normalizeJiraSavedFilterState,
mutateJiraSavedFilterState
@@ -19,6 +21,23 @@ import { TerminalQuickCommandsUpdate } from '../../../../shared/rpc-contract/ter
import { AgentLaunchSettingsMutation } from '../../../../shared/rpc-contract/agent-launch-settings-params'
export const CLIENT_UI_METHODS = [
+ defineMethod({
+ name: 'ui.getJiraQuickFilters',
+ permission: 'workspace',
+ params: null,
+ handler: (_params, { runtime }) =>
+ normalizeJiraQuickFilterState(runtime.getUIState().jiraQuickFilterState)
+ }),
+ defineMethod({
+ name: 'ui.setJiraQuickFilters',
+ permission: 'workspace',
+ params: JiraQuickFilterStateParam,
+ handler: (params, { runtime }) => {
+ const state = normalizeJiraQuickFilterState(params)
+ runtime.updateUIState({ jiraQuickFilterState: state })
+ return state
+ }
+ }),
defineMethod({
name: 'ui.getJiraSavedFilters',
permission: 'workspace',
diff --git a/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts b/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts
index 7fdf478647b..c57d5ef3be3 100644
--- a/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts
+++ b/src/main/runtime/rpc/methods/jira-saved-filter-state.test.ts
@@ -83,3 +83,27 @@ it('routes mobile saved-filter persistence to the execution host', () => {
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.getJiraSavedFilters')).toBe('execution-host')
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.mutateJiraSavedFilters')).toBe('execution-host')
})
+
+it('persists quick filters on the execution host without rewriting saved JQL or resume fields', async () => {
+ const { dispatch, read, updateUIState } = setup()
+ await dispatch('ui.mutateJiraSavedFilters', { kind: 'save', filter })
+ expect(
+ await dispatch('ui.setJiraQuickFilters', { statuses: ['In Progress', 'Blocked'] })
+ ).toMatchObject({
+ ok: true,
+ result: { statuses: ['In Progress', 'Blocked'] }
+ })
+ expect(await dispatch('ui.getJiraQuickFilters')).toMatchObject({
+ ok: true,
+ result: { statuses: ['In Progress', 'Blocked'] }
+ })
+ expect(read().jiraSavedFilterState?.filters).toEqual([filter])
+ expect(read().taskResumeState).toEqual({ jiraQuery: 'project = OLD' })
+ expect(updateUIState.mock.calls.every(([update]) => !('taskResumeState' in update))).toBe(true)
+ expect(await dispatch('ui.setJiraQuickFilters', { statuses: ['x'.repeat(129)] })).toMatchObject({
+ ok: false,
+ error: { code: 'invalid_argument' }
+ })
+ expect(MOBILE_RPC_METHOD_ROUTES.get('ui.getJiraQuickFilters')).toBe('execution-host')
+ expect(MOBILE_RPC_METHOD_ROUTES.get('ui.setJiraQuickFilters')).toBe('execution-host')
+})
diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts
index 4c3fae446f3..228109b8417 100644
--- a/src/main/runtime/rpc/methods/jira.test.ts
+++ b/src/main/runtime/rpc/methods/jira.test.ts
@@ -109,7 +109,13 @@ describe('jira RPC methods', () => {
undefined,
undefined
)
- expect(runtime.jiraListIssues).toHaveBeenCalledWith('assigned', 20, 'site-1', undefined)
+ expect(runtime.jiraListIssues).toHaveBeenCalledWith(
+ 'assigned',
+ 20,
+ 'site-1',
+ undefined,
+ undefined
+ )
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined)
expect(runtime.jiraLookupIssueSummary).toHaveBeenCalledWith(
'ABC-3',
@@ -281,6 +287,28 @@ describe('jira RPC methods', () => {
})
})
+it('forwards strict reads and optional status filters while accepting older requests', async () => {
+ const jiraListIssues = vi.fn().mockResolvedValue([])
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the runtime identity and list method are invoked by these requests.
+ const runtime = { getRuntimeId: () => 'host-a', jiraListIssues } as unknown as OrcaRuntimeService
+ const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS })
+ expect(
+ await dispatcher.dispatch(
+ makeRequest('jira.listIssues', {
+ filter: 'all',
+ siteId: 'site-1',
+ strict: true,
+ filters: { statuses: ['In Progress'] }
+ })
+ )
+ ).toMatchObject({ ok: true })
+ expect(jiraListIssues).toHaveBeenLastCalledWith('all', undefined, 'site-1', true, {
+ statuses: ['In Progress']
+ })
+ await dispatcher.dispatch(makeRequest('jira.listIssues', { filter: 'all' }))
+ expect(jiraListIssues).toHaveBeenLastCalledWith('all', undefined, undefined, undefined, undefined)
+})
+
describe('Jira title generation RPC', () => {
it('binds generation and stop to the authenticated device across one-shot sockets', async () => {
const generate = vi.fn().mockResolvedValue({ success: true, summary: 'Title' })
diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts
index 6460e1518d4..51ffffb0306 100644
--- a/src/main/runtime/rpc/methods/jira.ts
+++ b/src/main/runtime/rpc/methods/jira.ts
@@ -119,7 +119,13 @@ export const JIRA_METHODS = [
permission: 'workspace',
params: ListIssues,
handler: async (params, { runtime }) =>
- runtime.jiraListIssues(params?.filter, params?.limit, params?.siteId, params?.strict)
+ runtime.jiraListIssues(
+ params?.filter,
+ params?.limit,
+ params?.siteId,
+ params?.strict,
+ params?.filters
+ )
}),
defineMethod({
name: 'jira.getIssue',
diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts
index 8273895346f..17fb5523886 100644
--- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts
+++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts
@@ -10,6 +10,7 @@ import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-sche
// forgotten, which is what the parity assertion below enforces.
type MainOwnedUIState =
| 'jiraSavedFilterState'
+ | 'jiraQuickFilterState'
| '_explorerDisplayRootMigrated'
| 'trayMinimizeNoticeShown'
| 'dashboardPopoutBounds'
diff --git a/src/main/runtime/runtime-jira-commands.ts b/src/main/runtime/runtime-jira-commands.ts
index ac4353007f9..c30ba5c34a9 100644
--- a/src/main/runtime/runtime-jira-commands.ts
+++ b/src/main/runtime/runtime-jira-commands.ts
@@ -11,6 +11,7 @@ import type {
JiraConnectArgs,
JiraCreateIssueArgs,
JiraIssueFilter,
+ JiraIssueListFilters,
JiraIssueUpdate,
JiraSiteSelection
} from '../../shared/jira-types'
@@ -92,9 +93,10 @@ export class RuntimeJiraCommands {
filter?: JiraIssueFilter,
limit = 30,
siteId?: JiraSiteSelection,
- strict = false
+ strict = false,
+ filters?: JiraIssueListFilters
): ReturnType {
- return listIssues(filter, Math.min(Math.max(1, limit), 100), siteId, strict)
+ return listIssues(filter, Math.min(Math.max(1, limit), 100), siteId, strict, filters)
}
jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType {
diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts
index d88eb71f603..7587fd5260c 100644
--- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts
+++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-routing.ts
@@ -6,6 +6,8 @@ import { PAIRED_DESKTOP_METHODS } from './runtime-rpc-mobile-paired-desktop-meth
export type MobileRpcMethodRoute = 'execution-host' | 'paired-desktop'
export const EXECUTION_HOST_METHODS = [
+ 'ui.getJiraQuickFilters',
+ 'ui.setJiraQuickFilters',
'ui.getJiraSavedFilters',
'ui.mutateJiraSavedFilters',
'agent.launch',
diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts
index 813ef436b99..f8e965d5384 100644
--- a/src/preload/api/jira-api.ts
+++ b/src/preload/api/jira-api.ts
@@ -10,6 +10,7 @@ import type {
JiraCreateIssueArgs,
JiraIssue,
JiraIssueFilter,
+ JiraIssueListFilters,
JiraIssueType,
JiraIssueUpdate,
JiraPriority,
@@ -46,6 +47,7 @@ export type JiraApi = {
filter?: JiraIssueFilter
limit?: number
siteId?: JiraSiteSelection
+ filters?: JiraIssueListFilters
}) => Promise
getIssue: (args: { key: string; siteId?: string }) => Promise
lookupIssueSummary: (args: {
diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts
index d7720c3ab07..f58b51334a1 100644
--- a/src/preload/api/ui-bridge-state-and-menu-commands.ts
+++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts
@@ -5,6 +5,8 @@ import type { KeybindingActionId } from '../../shared/keybindings'
import type { PreloadApi } from '../api-types'
export const uiStateAndMenuCommandsApi = {
+ getJiraQuickFilters: () => ipcRenderer.invoke('ui:getJiraQuickFilters'),
+ setJiraQuickFilters: (state) => ipcRenderer.invoke('ui:setJiraQuickFilters', state),
getJiraSavedFilters: () => ipcRenderer.invoke('ui:getJiraSavedFilters'),
mutateJiraSavedFilters: (mutation) => ipcRenderer.invoke('ui:mutateJiraSavedFilters', mutation),
get: () => ipcRenderer.invoke('ui:get'),
diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts
index 9f4e0c397b0..a00a24dbfe4 100644
--- a/src/preload/api/ui-command-event-api.ts
+++ b/src/preload/api/ui-command-event-api.ts
@@ -1,4 +1,5 @@
import type { RuntimeFileOpenPosition } from '../../shared/runtime-file-contracts'
+import type { JiraQuickFilterState } from '../../shared/jira-quick-filters'
import type { JiraSavedFilterState, JiraSavedFilterMutation } from '../../shared/jira-saved-filters'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
@@ -51,6 +52,8 @@ import type {
export type CloseActiveTabPayload = { sourceId: string }
export type UiCommandEventApi = {
+ getJiraQuickFilters?: () => Promise
+ setJiraQuickFilters?: (state: JiraQuickFilterState) => Promise
getJiraSavedFilters?: () => Promise
mutateJiraSavedFilters?: (mutation: JiraSavedFilterMutation) => Promise
get: () => Promise
diff --git a/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx b/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx
new file mode 100644
index 00000000000..e5add67d0bd
--- /dev/null
+++ b/src/renderer/src/components/TaskPageJiraStatusFilters.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters'
+import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
+afterEach(cleanup)
+function model(): TaskPageComposerActionsModel {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every field read by the status-controls component.
+ return {
+ jiraHasWorkspace: false,
+ selectedJiraStatuses: ['Developed'],
+ selectJiraStatuses: vi.fn().mockResolvedValue(undefined),
+ jiraQuickFiltersReady: true,
+ jiraQuickFiltersPending: false,
+ jiraQuickFiltersLoading: false,
+ jiraQuickFiltersError: null,
+ retryJiraQuickFilters: vi.fn(),
+ jiraStatusCatalog: null,
+ jiraTaskSourceScopeKey: 'jira-owner',
+ selectedJiraSiteId: 'site',
+ activeJiraPreset: 'assigned',
+ jiraSearchInput: '',
+ setJiraSearchInput: vi.fn(),
+ setAppliedJiraSearch: vi.fn(),
+ clearJiraSavedFilterSelection: vi.fn().mockResolvedValue(undefined),
+ jiraSavedFiltersPending: false
+ } as unknown as TaskPageComposerActionsModel
+}
+it('keeps a selected status and reachable Clear when the loaded catalog is empty', () => {
+ const state = model()
+ render()
+ expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe(
+ 'true'
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Clear statuses' }))
+ expect(state.selectJiraStatuses).toHaveBeenCalledWith([])
+})
+it('multi-selects actual statuses and hands the composed query to manual editing', async () => {
+ const state = model()
+ state.selectedJiraStatuses = ['In Progress']
+ state.jiraStatusCatalog = {
+ scopeKey: 'jira-owner::site::assigned',
+ items: [
+ { name: 'Blocked', categoryKey: 'indeterminate' },
+ { name: 'In Progress', categoryKey: 'indeterminate' }
+ ]
+ }
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Blocked' }))
+ expect(state.selectJiraStatuses).toHaveBeenCalledWith(['In Progress', 'Blocked'])
+ fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' }))
+ await waitFor(() =>
+ expect(state.setJiraSearchInput).toHaveBeenCalledWith(
+ 'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
+ )
+ )
+ expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(
+ 'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
+ )
+})
+it('hides status controls and preserves an existing manual query', () => {
+ const state = model()
+ state.jiraSearchInput = 'project = CUSTOM ORDER BY created ASC'
+ render()
+ expect(screen.queryByRole('button', { name: 'Developed' })).toBeNull()
+ expect(screen.getByText(/Status filters apply to preset views/)).toBeDefined()
+ expect(state.setJiraSearchInput).not.toHaveBeenCalled()
+})
+it('hides statuses and query preview in linked mode without clearing selection', () => {
+ const state = model()
+ state.jiraHasWorkspace = true
+ const { container } = render()
+ expect(container.innerHTML).toBe('')
+ expect(state.selectJiraStatuses).not.toHaveBeenCalled()
+ expect(state.selectedJiraStatuses).toEqual(['Developed'])
+})
diff --git a/src/renderer/src/components/TaskPageJiraStatusFilters.tsx b/src/renderer/src/components/TaskPageJiraStatusFilters.tsx
new file mode 100644
index 00000000000..aebedd43952
--- /dev/null
+++ b/src/renderer/src/components/TaskPageJiraStatusFilters.tsx
@@ -0,0 +1,141 @@
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import { translate } from '@/i18n/i18n'
+import { buildJiraPresetJql } from '../../../shared/jira-preset-jql'
+import { includeSelectedJiraStatuses } from './task-page-jira-status-catalog'
+import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
+
+export function TaskPageJiraStatusFilters({
+ model
+}: {
+ model: TaskPageComposerActionsModel
+}): React.JSX.Element | null {
+ const {
+ jiraHasWorkspace,
+ selectedJiraStatuses,
+ selectJiraStatuses,
+ jiraQuickFiltersReady,
+ jiraQuickFiltersPending,
+ jiraQuickFiltersLoading,
+ jiraQuickFiltersError,
+ retryJiraQuickFilters,
+ jiraStatusCatalog,
+ jiraTaskSourceScopeKey,
+ selectedJiraSiteId,
+ activeJiraPreset,
+ jiraSearchInput,
+ setJiraSearchInput,
+ setAppliedJiraSearch,
+ clearJiraSavedFilterSelection,
+ jiraSavedFiltersPending
+ } = model
+ if (jiraHasWorkspace) {
+ return null
+ }
+ const scopeKey = `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`
+ const statuses = includeSelectedJiraStatuses(
+ jiraStatusCatalog?.scopeKey === scopeKey ? jiraStatusCatalog.items : [],
+ selectedJiraStatuses
+ )
+ const preview = buildJiraPresetJql(activeJiraPreset, { statuses: selectedJiraStatuses })
+ const disabled =
+ !jiraQuickFiltersReady ||
+ jiraQuickFiltersLoading ||
+ jiraQuickFiltersPending ||
+ jiraSavedFiltersPending
+ if (jiraSearchInput.trim()) {
+ return selectedJiraStatuses.length ? (
+
+ {translate(
+ 'jira.statusFilters.manualQuery',
+ 'Status filters apply to preset views. Clear the search to use them.'
+ )}
+
+ ) : null
+ }
+ return (
+
+ {jiraQuickFiltersError ? (
+
+ {jiraQuickFiltersError}
+
+
+ ) : null}
+ {statuses.length ? (
+
+ {
+ void selectJiraStatuses(next).catch(() => {})
+ }}
+ className="flex-wrap"
+ >
+ {statuses.map((status) => (
+
+ {status.name}
+
+ ))}
+
+ {selectedJiraStatuses.length ? (
+
+ ) : null}
+
+ ) : null}
+ {selectedJiraStatuses.length ? (
+ <>
+
+
+
+
+
+ {translate(
+ 'jira.statusFilters.catalogHint',
+ 'Statuses come from loaded issues. Clear statuses to discover others in this view.'
+ )}
+
+ >
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/task-page-jira-status-catalog.test.ts b/src/renderer/src/components/task-page-jira-status-catalog.test.ts
new file mode 100644
index 00000000000..4aeac5532d4
--- /dev/null
+++ b/src/renderer/src/components/task-page-jira-status-catalog.test.ts
@@ -0,0 +1,24 @@
+import { expect, it } from 'vitest'
+import {
+ includeSelectedJiraStatuses,
+ orderJiraStatusCatalog
+} from './task-page-jira-status-catalog'
+
+it('orders actual workflow statuses by category and name', () => {
+ expect(
+ orderJiraStatusCatalog([
+ { name: 'Done', categoryKey: 'done' },
+ { name: 'Ready', categoryKey: 'indeterminate' },
+ { name: 'Open', categoryKey: 'new' },
+ { name: 'Blocked', categoryKey: 'indeterminate' }
+ ]).map((status) => status.name)
+ ).toEqual(['Open', 'Blocked', 'Ready', 'Done'])
+})
+it('keeps selected statuses visible even when a new scope has not loaded them', () => {
+ expect(
+ includeSelectedJiraStatuses([{ name: 'Open', categoryKey: 'new' }], ['Open', 'Developed'])
+ ).toEqual([
+ { name: 'Open', categoryKey: 'new' },
+ { name: 'Developed', categoryKey: 'other' }
+ ])
+})
diff --git a/src/renderer/src/components/task-page-jira-status-catalog.ts b/src/renderer/src/components/task-page-jira-status-catalog.ts
new file mode 100644
index 00000000000..bde6f159a7e
--- /dev/null
+++ b/src/renderer/src/components/task-page-jira-status-catalog.ts
@@ -0,0 +1,44 @@
+import type { JiraIssue } from '../../../shared/jira-types'
+
+export type JiraStatusCatalogItem = { name: string; categoryKey: string }
+const STATUS_NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' })
+const STATUS_NAME_TIE_BREAKER = new Intl.Collator('en')
+const STATUS_CATEGORY_ORDER = new Map([
+ ['new', 0],
+ ['indeterminate', 1],
+ ['done', 2]
+])
+
+export function buildJiraStatusCatalog(issues: readonly JiraIssue[]): JiraStatusCatalogItem[] {
+ const statuses = new Map()
+ for (const { status } of issues) {
+ if (!statuses.has(status.name)) {
+ statuses.set(status.name, { name: status.name, categoryKey: status.categoryKey })
+ }
+ }
+ return orderJiraStatusCatalog([...statuses.values()])
+}
+
+export function orderJiraStatusCatalog(items: JiraStatusCatalogItem[]): JiraStatusCatalogItem[] {
+ return [...items].sort((left, right) => {
+ const category =
+ (STATUS_CATEGORY_ORDER.get(left.categoryKey) ?? 3) -
+ (STATUS_CATEGORY_ORDER.get(right.categoryKey) ?? 3)
+ return (
+ category ||
+ STATUS_NAME_COLLATOR.compare(left.name, right.name) ||
+ STATUS_NAME_TIE_BREAKER.compare(left.name, right.name)
+ )
+ })
+}
+
+export function includeSelectedJiraStatuses(
+ catalog: JiraStatusCatalogItem[],
+ selected: string[]
+): JiraStatusCatalogItem[] {
+ const known = new Set(catalog.map((status) => status.name))
+ return orderJiraStatusCatalog([
+ ...catalog,
+ ...selected.filter((name) => !known.has(name)).map((name) => ({ name, categoryKey: 'other' }))
+ ])
+}
diff --git a/src/renderer/src/components/task-page/jira/Filters.tsx b/src/renderer/src/components/task-page/jira/Filters.tsx
index 0cdb4356feb..1ba65cbc300 100644
--- a/src/renderer/src/components/task-page/jira/Filters.tsx
+++ b/src/renderer/src/components/task-page/jira/Filters.tsx
@@ -1,3 +1,4 @@
+import { TaskPageJiraStatusFilters } from '../../TaskPageJiraStatusFilters'
import { TaskPageJiraSavedFilters } from '../../TaskPageJiraSavedFilters'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { cn } from '@/lib/utils'
@@ -177,6 +178,7 @@ export function TaskPageJiraFilters({
{jiraSavedFiltersError}
) : null}
+
diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts
index 76bbc47e0a3..87f2d0bfb17 100644
--- a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts
+++ b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts
@@ -40,6 +40,9 @@ function createModel(): TaskPageLinearCollectionEffectsModel {
jiraActiveSavedFilterId: null,
jiraSavedFilters: [],
jiraSavedFiltersLoading: false,
+ selectedJiraStatuses: [],
+ jiraQuickFiltersLoading: false,
+ setJiraStatusCatalog: vi.fn(),
setJiraProjectStatusOrder: vi.fn(),
displayedJiraIssues: []
}
@@ -140,6 +143,40 @@ describe('useTaskPageJiraListEffects refresh wiring', () => {
})
})
+ it('uses the preset list path for status filters and restores it after clearing', async () => {
+ const model = createModel()
+ model.selectedJiraStatuses = ['In Progress', 'Blocked']
+ const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
+ await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(1))
+ expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
+ sourceContext: null,
+ force: false,
+ filters: { statuses: ['In Progress', 'Blocked'] }
+ })
+ expect(searchJiraIssues).not.toHaveBeenCalled()
+ await act(async () => {
+ model.selectedJiraStatuses = []
+ rerender()
+ })
+ expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
+ sourceContext: null,
+ force: false
+ })
+ })
+
+ it('keeps manual JQL independent of a persisted status selection', async () => {
+ const model = createModel()
+ model.selectedJiraStatuses = ['Blocked']
+ model.appliedJiraSearch = 'project = APP ORDER BY created ASC'
+ renderHook(() => useTaskPageJiraListEffects(model))
+ await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
+ expect(searchJiraIssues).toHaveBeenCalledWith('project = APP ORDER BY created ASC', 50, {
+ sourceContext: null,
+ force: false
+ })
+ expect(listJiraIssues).not.toHaveBeenCalled()
+ })
+
it('passes force through the text-search wrapper on refresh', async () => {
const model = createModel()
model.appliedJiraSearch = 'fix login'
diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.ts b/src/renderer/src/components/use-task-page-jira-list-effects.ts
index 060f8376027..6b3945b450e 100644
--- a/src/renderer/src/components/use-task-page-jira-list-effects.ts
+++ b/src/renderer/src/components/use-task-page-jira-list-effects.ts
@@ -1,3 +1,4 @@
+import { buildJiraStatusCatalog } from './task-page-jira-status-catalog'
import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects'
import { useEffect, useRef } from 'react'
import {
@@ -43,6 +44,9 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
jiraActiveSavedFilterId,
jiraSavedFilters,
jiraSavedFiltersLoading,
+ selectedJiraStatuses,
+ jiraQuickFiltersLoading,
+ setJiraStatusCatalog,
setJiraProjectStatusOrder,
displayedJiraIssues
} = model
@@ -76,7 +80,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
if (taskSource !== 'jira') {
return
}
- if (!jiraConnected || jiraSavedFiltersLoading) {
+ if (!jiraConnected || jiraSavedFiltersLoading || jiraQuickFiltersLoading) {
return
}
const force = previousJiraRefreshNonceRef.current !== jiraRefreshNonce
@@ -103,7 +107,8 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
).then((result) => ({ ...result, error: null }))
: listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, {
sourceContext: jiraTaskSourceContext,
- force
+ force,
+ ...(selectedJiraStatuses.length ? { filters: { statuses: selectedJiraStatuses } } : {})
}).then((issues) => ({ issues, jqlRejection: null, error: null }))
void request
.then((result) => {
@@ -112,6 +117,12 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
return
}
setJiraError(result.error)
+ if (!jiraHasWorkspace && !trimmed && selectedJiraStatuses.length === 0) {
+ setJiraStatusCatalog({
+ scopeKey: `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`,
+ items: buildJiraStatusCatalog(issues)
+ })
+ }
setJiraIssues(issues)
setJiraJqlRejection(jqlRejection)
setJiraLoading(false)
@@ -161,6 +172,8 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
jiraActiveSavedFilterId,
jiraSavedFilters,
jiraSavedFiltersLoading,
+ selectedJiraStatuses,
+ jiraQuickFiltersLoading,
taskResumeApplied,
jiraTaskSourceContext,
jiraTaskSourceScopeKey
diff --git a/src/renderer/src/components/use-task-page-jira-list-state.ts b/src/renderer/src/components/use-task-page-jira-list-state.ts
index d734fd9902c..0c6d3053d10 100644
--- a/src/renderer/src/components/use-task-page-jira-list-state.ts
+++ b/src/renderer/src/components/use-task-page-jira-list-state.ts
@@ -1,3 +1,5 @@
+import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
+import type { JiraStatusCatalogItem } from './task-page-jira-status-catalog'
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state'
import { useState, useMemo, useEffect, useCallback } from 'react'
@@ -58,6 +60,11 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
setAppliedJiraSearch(jql)
}, [])
const savedFilters = useTaskPageJiraSavedFilters(model, setSavedFilterQuery)
+ const quickFilters = useTaskPageJiraQuickFilters(model)
+ const [jiraStatusCatalog, setJiraStatusCatalog] = useState<{
+ scopeKey: string
+ items: JiraStatusCatalogItem[]
+ } | null>(null)
const [activeJiraPreset, setActiveJiraPreset] = useState
('assigned')
const [jiraRefreshNonce, setJiraRefreshNonce] = useState(0)
const [jiraProjectStatusOrder, setJiraProjectStatusOrder] = useState<{
@@ -157,6 +164,9 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
jiraHasWorkspace,
setJiraHasWorkspace,
...savedFilters,
+ ...quickFilters,
+ jiraStatusCatalog,
+ setJiraStatusCatalog,
jiraIssues,
setJiraIssues,
jiraLoading,
diff --git a/src/renderer/src/components/use-task-page-jira-quick-filters.test.tsx b/src/renderer/src/components/use-task-page-jira-quick-filters.test.tsx
new file mode 100644
index 00000000000..8264e1814ad
--- /dev/null
+++ b/src/renderer/src/components/use-task-page-jira-quick-filters.test.tsx
@@ -0,0 +1,220 @@
+// @vitest-environment happy-dom
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
+import type { JiraQuickFilterState } from '../../../shared/jira-quick-filters'
+import { UNRESOLVED_OWNER_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host'
+const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() }))
+vi.mock('@/runtime/runtime-jira-quick-filter-state', () => ({
+ readJiraQuickFilterState: read,
+ writeJiraQuickFilterState: write
+}))
+beforeEach(() => {
+ vi.resetAllMocks()
+})
+afterEach(cleanup)
+function model(
+ host: string | null,
+ accountHost = 'account-owner'
+): Parameters[0] {
+ return {
+ accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost),
+ taskSource: 'jira',
+ taskResumeApplied: true,
+ jiraTaskSourceContext: host
+ ? {
+ kind: 'task-source',
+ provider: 'jira',
+ projectId: 'project',
+ hostId: toRuntimeExecutionHostId(host)
+ }
+ : null
+ }
+}
+it('uses the captured account owner without a source context and keeps source ownership first', async () => {
+ read.mockResolvedValue({ statuses: ['Blocked'] })
+ write.mockResolvedValue({ statuses: ['Open'] })
+ const { result, rerender } = renderHook(
+ ({ sourceHost }) => useTaskPageJiraQuickFilters(model(sourceHost)),
+ { initialProps: { sourceHost: '' } }
+ )
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'account-owner' })
+ await act(async () => result.current.selectJiraStatuses(['Open']))
+ expect(write).toHaveBeenLastCalledWith(
+ { kind: 'environment', environmentId: 'account-owner' },
+ { statuses: ['Open'] }
+ )
+ rerender({ sourceHost: 'source-owner' })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'source-owner' })
+})
+it('does not load or write status filters for an unresolved owner', async () => {
+ const { result } = renderHook(() =>
+ useTaskPageJiraQuickFilters({
+ ...model(null),
+ accountBackedTaskSourceHostId: UNRESOLVED_OWNER_HOST_ID
+ })
+ )
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ await act(async () => result.current.selectJiraStatuses(['Open']))
+ expect(result.current.jiraQuickFiltersReady).toBe(false)
+ expect(read).not.toHaveBeenCalled()
+ expect(write).not.toHaveBeenCalled()
+})
+it('restores selection after remount and waits for writes to reach the host', async () => {
+ read
+ .mockResolvedValueOnce({ statuses: ['Blocked'] })
+ .mockResolvedValueOnce({ statuses: ['In Progress'] })
+ let finish: (value: JiraQuickFilterState) => void = () => {}
+ write.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ const first = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
+ await waitFor(() => expect(first.result.current.jiraQuickFiltersLoading).toBe(false))
+ let pending: Promise = Promise.resolve()
+ act(() => {
+ pending = first.result.current.selectJiraStatuses(['In Progress'])
+ })
+ expect(first.result.current.selectedJiraStatuses).toEqual(['Blocked'])
+ expect(first.result.current.jiraQuickFiltersPending).toBe(true)
+ await act(async () => {
+ finish({ statuses: ['In Progress'] })
+ await pending
+ })
+ expect(first.result.current.selectedJiraStatuses).toEqual(['In Progress'])
+ first.unmount()
+ const restored = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
+ await waitFor(() => expect(restored.result.current.selectedJiraStatuses).toEqual(['In Progress']))
+})
+it('retains selection after a failed write', async () => {
+ read.mockResolvedValue({ statuses: ['Blocked'] })
+ write.mockRejectedValue(new Error('Host unreachable'))
+ const { result } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ await act(async () => {
+ await expect(result.current.selectJiraStatuses([])).rejects.toThrow('Host unreachable')
+ })
+ expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
+ expect(result.current.jiraQuickFiltersError).toBe('Host unreachable')
+})
+it('ignores a late mutation reply after changing Jira execution hosts', async () => {
+ read
+ .mockResolvedValueOnce({ statuses: ['Blocked'] })
+ .mockResolvedValueOnce({ statuses: ['Open'] })
+ let finish: (value: JiraQuickFilterState) => void = () => {}
+ write.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
+ initialProps: { host: 'a' }
+ })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ let pending: Promise = Promise.resolve()
+ act(() => {
+ pending = result.current.selectJiraStatuses(['In Progress'])
+ })
+ rerender({ host: 'b' })
+ await waitFor(() => expect(result.current.selectedJiraStatuses).toEqual(['Open']))
+ await act(async () => {
+ finish({ statuses: ['In Progress'] })
+ await pending
+ })
+ expect(result.current.selectedJiraStatuses).toEqual(['Open'])
+})
+it('keeps unsupported-host empty selection stable across renders', async () => {
+ read.mockRejectedValue(new Error('Update host'))
+ const { result, rerender } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ const first = result.current.selectedJiraStatuses
+ rerender()
+ expect(result.current.selectedJiraStatuses).toBe(first)
+ expect(result.current.jiraQuickFiltersReady).toBe(false)
+})
+it('does not send a status filter action retained from a previous account owner', async () => {
+ read.mockResolvedValue({ statuses: ['Blocked'] })
+ const { result, rerender } = renderHook(
+ ({ accountHost }) => useTaskPageJiraQuickFilters(model(null, accountHost)),
+ { initialProps: { accountHost: 'host-a' } }
+ )
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ const oldAction = result.current.selectJiraStatuses
+ rerender({ accountHost: 'host-b' })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ await act(async () => oldAction(['Open']))
+ expect(write).not.toHaveBeenCalled()
+})
+it('rejects a retained status action after returning to its earlier owner', async () => {
+ read.mockResolvedValue({ statuses: ['Open'] })
+ write.mockResolvedValue({ statuses: ['Closed'] })
+ const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
+ initialProps: { host: 'a' }
+ })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ const oldAction = result.current.selectJiraStatuses
+ rerender({ host: 'b' })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ rerender({ host: 'a' })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ await act(async () => oldAction(['Closed']))
+ expect(write).not.toHaveBeenCalled()
+})
+it.each(['success', 'failure'] as const)(
+ 'keeps a new owner visit pending after an old %s settlement',
+ async (outcome) => {
+ read.mockResolvedValue({ statuses: ['Open'] })
+ let finishOld: (state: JiraQuickFilterState) => void = () => {}
+ let failOld: (reason: Error) => void = () => {}
+ let finishNew: (state: JiraQuickFilterState) => void = () => {}
+ write
+ .mockReturnValueOnce(
+ new Promise((resolve, reject) => {
+ finishOld = resolve
+ failOld = reject
+ })
+ )
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishNew = resolve
+ })
+ )
+ const { result, rerender } = renderHook(
+ ({ host }) => useTaskPageJiraQuickFilters(model(host)),
+ { initialProps: { host: 'a' } }
+ )
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ let oldWrite = Promise.resolve()
+ act(() => {
+ oldWrite = result.current.selectJiraStatuses(['Closed']).catch(() => {})
+ })
+ rerender({ host: 'b' })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ rerender({ host: 'a' })
+ await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
+ let newWrite = Promise.resolve()
+ act(() => {
+ newWrite = result.current.selectJiraStatuses(['In Progress'])
+ })
+ await act(async () => {
+ if (outcome === 'success') {
+ finishOld({ statuses: ['Closed'] })
+ } else {
+ failOld(new Error('Old request failed'))
+ }
+ await oldWrite
+ })
+ expect(result.current.jiraQuickFiltersPending).toBe(true)
+ expect(result.current.selectedJiraStatuses).toEqual(['Open'])
+ expect(result.current.jiraQuickFiltersError).toBeNull()
+ await act(async () => {
+ finishNew({ statuses: ['In Progress'] })
+ await newWrite
+ })
+ expect(result.current.selectedJiraStatuses).toEqual(['In Progress'])
+ expect(result.current.jiraQuickFiltersPending).toBe(false)
+ }
+)
diff --git a/src/renderer/src/components/use-task-page-jira-quick-filters.ts b/src/renderer/src/components/use-task-page-jira-quick-filters.ts
new file mode 100644
index 00000000000..c8583e7d128
--- /dev/null
+++ b/src/renderer/src/components/use-task-page-jira-quick-filters.ts
@@ -0,0 +1,123 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
+import {
+ readJiraQuickFilterState,
+ writeJiraQuickFilterState
+} from '@/runtime/runtime-jira-quick-filter-state'
+import {
+ normalizeJiraQuickFilterState,
+ type JiraQuickFilterState
+} from '../../../shared/jira-quick-filters'
+import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state'
+import { translate } from '@/i18n/i18n'
+
+const EMPTY_QUICK_FILTER_STATE: JiraQuickFilterState = { statuses: [] }
+type JiraQuickFilterModel = Pick<
+ TaskPageLinearViewStateModel,
+ 'jiraTaskSourceContext' | 'accountBackedTaskSourceHostId' | 'taskResumeApplied' | 'taskSource'
+>
+
+export function useTaskPageJiraQuickFilters(model: JiraQuickFilterModel) {
+ const ownerKey = model.jiraTaskSourceContext?.hostId ?? model.accountBackedTaskSourceHostId
+ const target = useMemo(() => ipcOrRuntimeTargetForHostId(ownerKey), [ownerKey])
+ const [snapshot, setSnapshot] = useState({
+ ownerKey: '',
+ state: normalizeJiraQuickFilterState(undefined)
+ })
+ const [loading, setLoading] = useState(true)
+ const lifetime = useMemo(() => ({ ownerKey }), [ownerKey])
+ const [pendingLifetime, setPendingLifetime] = useState(null)
+ const [error, setError] = useState(null)
+ const [refresh, setRefresh] = useState(0)
+ const lifetimeRef = useRef(null)
+ const writePendingRef = useRef(null)
+ useLayoutEffect(() => {
+ lifetimeRef.current = lifetime
+ return () => {
+ lifetimeRef.current = null
+ }
+ }, [lifetime])
+ const ready = target !== null && snapshot.ownerKey === ownerKey
+ const state = ready ? snapshot.state : EMPTY_QUICK_FILTER_STATE
+ useEffect(() => {
+ if (!model.taskResumeApplied || model.taskSource !== 'jira') {
+ return
+ }
+ let cancelled = false
+ setLoading(true)
+ setError(null)
+ const load = async () => {
+ try {
+ if (!target) {
+ return
+ }
+ const next = await readJiraQuickFilterState(target)
+ if (!cancelled) {
+ setSnapshot({ ownerKey, state: next })
+ }
+ } catch (reason) {
+ if (!cancelled) {
+ setError(
+ reason instanceof Error
+ ? reason.message
+ : translate('jira.statusFilters.loadFailed', 'Could not load Jira status filters.')
+ )
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ }
+ }
+ void load()
+ return () => {
+ cancelled = true
+ }
+ }, [ownerKey, target, model.taskResumeApplied, model.taskSource, refresh])
+
+ const select = async (statuses: string[]): Promise => {
+ if (
+ !ready ||
+ loading ||
+ writePendingRef.current === lifetime ||
+ !target ||
+ lifetimeRef.current !== lifetime
+ ) {
+ return
+ }
+ const expectedOwner = ownerKey
+ const stillCurrent = () => lifetimeRef.current === lifetime
+ writePendingRef.current = lifetime
+ setPendingLifetime(lifetime)
+ setError(null)
+ try {
+ const next = await writeJiraQuickFilterState(target, { statuses })
+ if (stillCurrent()) {
+ setSnapshot({ ownerKey: expectedOwner, state: next })
+ }
+ } catch (reason) {
+ if (stillCurrent()) {
+ setError(
+ reason instanceof Error
+ ? reason.message
+ : translate('jira.statusFilters.saveFailed', 'Could not save Jira status filters.')
+ )
+ }
+ throw reason
+ } finally {
+ if (stillCurrent()) {
+ writePendingRef.current = null
+ setPendingLifetime(null)
+ }
+ }
+ }
+ return {
+ selectedJiraStatuses: state.statuses,
+ jiraQuickFiltersReady: ready,
+ jiraQuickFiltersLoading: loading,
+ jiraQuickFiltersPending: pendingLifetime === lifetime,
+ jiraQuickFiltersError: error,
+ retryJiraQuickFilters: () => setRefresh((value) => value + 1),
+ selectJiraStatuses: select
+ }
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 4c931800c77..c7d063e0588 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -20045,6 +20045,16 @@
"noMatch": "No matching filters.",
"loadFailed": "Could not load saved Jira filters.",
"saveFailed": "Could not save Jira filters."
+ },
+ "statusFilters": {
+ "loadFailed": "Could not load Jira status filters.",
+ "saveFailed": "Could not save Jira status filters.",
+ "manualQuery": "Status filters apply to preset views. Clear the search to use them.",
+ "label": "Filter Jira issues by status",
+ "clear": "Clear statuses",
+ "jql": "Status filter JQL",
+ "editJql": "Edit as JQL",
+ "catalogHint": "Statuses come from loaded issues. Clear statuses to discover others in this view."
}
}
}
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index b6ad38595dd..4b4a2424536 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -19964,6 +19964,16 @@
"noMatch": "No hay filtros coincidentes.",
"loadFailed": "No se pudieron cargar los filtros guardados de Jira.",
"saveFailed": "No se pudieron guardar los filtros de Jira."
+ },
+ "statusFilters": {
+ "loadFailed": "No se pudieron cargar los filtros de estado de Jira.",
+ "saveFailed": "No se pudieron guardar los filtros de estado de Jira.",
+ "manualQuery": "Los filtros de estado se aplican a las vistas predefinidas. Borra la búsqueda para usarlos.",
+ "label": "Filtrar incidencias de Jira por estado",
+ "clear": "Borrar estados",
+ "jql": "JQL del filtro de estado",
+ "editJql": "Editar como JQL",
+ "catalogHint": "Los estados provienen de las incidencias cargadas. Borra los estados para descubrir otros en esta vista."
}
}
}
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index c060d3d1602..be2e8712ee5 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -19933,6 +19933,16 @@
"noMatch": "Aucun filtre correspondant.",
"loadFailed": "Impossible de charger les filtres Jira enregistrés.",
"saveFailed": "Impossible d’enregistrer les filtres Jira."
+ },
+ "statusFilters": {
+ "loadFailed": "Impossible de charger les filtres de statut Jira.",
+ "saveFailed": "Impossible d’enregistrer les filtres de statut Jira.",
+ "manualQuery": "Les filtres de statut s’appliquent aux vues prédéfinies. Effacez la recherche pour les utiliser.",
+ "label": "Filtrer les tickets Jira par statut",
+ "clear": "Effacer les statuts",
+ "jql": "JQL du filtre de statut",
+ "editJql": "Modifier en JQL",
+ "catalogHint": "Les statuts proviennent des tickets chargés. Effacez les statuts pour en découvrir d’autres dans cette vue."
}
}
}
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 36fe66a128d..c506df1cadb 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -19933,6 +19933,16 @@
"noMatch": "一致するフィルターはありません。",
"loadFailed": "Jiraの保存済みフィルターを読み込めませんでした。",
"saveFailed": "Jiraフィルターを保存できませんでした。"
+ },
+ "statusFilters": {
+ "loadFailed": "Jira ステータスフィルターを読み込めませんでした。",
+ "saveFailed": "Jira ステータスフィルターを保存できませんでした。",
+ "manualQuery": "ステータスフィルターはプリセットビューに適用されます。使用するには検索をクリアしてください。",
+ "label": "ステータスで Jira 課題を絞り込む",
+ "clear": "ステータスをクリア",
+ "jql": "ステータスフィルターの JQL",
+ "editJql": "JQL として編集",
+ "catalogHint": "ステータスは読み込まれた課題から取得されます。このビューで他のステータスを表示するには、選択をクリアしてください。"
}
}
}
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 5c6da385e55..ca8c8aa97ff 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -19933,6 +19933,16 @@
"noMatch": "일치하는 필터가 없습니다.",
"loadFailed": "저장된 Jira 필터를 불러올 수 없습니다.",
"saveFailed": "Jira 필터를 저장할 수 없습니다."
+ },
+ "statusFilters": {
+ "loadFailed": "Jira 상태 필터를 불러올 수 없습니다.",
+ "saveFailed": "Jira 상태 필터를 저장할 수 없습니다.",
+ "manualQuery": "상태 필터는 사전 설정 보기에 적용됩니다. 사용하려면 검색을 지우세요.",
+ "label": "상태별로 Jira 이슈 필터링",
+ "clear": "상태 지우기",
+ "jql": "상태 필터 JQL",
+ "editJql": "JQL로 편집",
+ "catalogHint": "상태는 불러온 이슈에서 가져옵니다. 이 보기에서 다른 상태를 찾으려면 선택을 지우세요."
}
}
}
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 1a0086e5820..bae98dd0f02 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -19933,6 +19933,16 @@
"noMatch": "没有匹配的筛选器。",
"loadFailed": "无法加载已保存的 Jira 筛选器。",
"saveFailed": "无法保存 Jira 筛选器。"
+ },
+ "statusFilters": {
+ "loadFailed": "无法加载 Jira 状态筛选器。",
+ "saveFailed": "无法保存 Jira 状态筛选器。",
+ "manualQuery": "状态筛选器适用于预设视图。清除搜索后即可使用。",
+ "label": "按状态筛选 Jira 议题",
+ "clear": "清除状态",
+ "jql": "状态筛选器 JQL",
+ "editJql": "作为 JQL 编辑",
+ "catalogHint": "状态来自已加载的议题。清除状态选择以查看此视图中的其他状态。"
}
}
}
diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts
index 68fa5e71657..80f745869d7 100644
--- a/src/renderer/src/runtime/runtime-jira-client.ts
+++ b/src/renderer/src/runtime/runtime-jira-client.ts
@@ -1,3 +1,7 @@
+import {
+ JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
+ JIRA_STATUS_FILTER_UPDATE_REQUIRED
+} from '../../../shared/jira-quick-filters'
import type {
JiraAuthType,
JiraComment,
@@ -5,6 +9,7 @@ import type {
JiraCreateField,
JiraIssue,
JiraIssueFilter,
+ JiraIssueListFilters,
JiraIssueType,
JiraIssueUpdate,
JiraMutationResult,
@@ -17,7 +22,7 @@ import type {
} from '../../../shared/jira-types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { searchLocalJiraIssues } from './local-jira-search-cancellation'
-import { callRuntimeRpc } from './runtime-rpc-client'
+import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target'
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
@@ -150,13 +155,32 @@ export async function jiraListIssues(
settings: RuntimeJiraSettings,
filter?: JiraIssueFilter,
limit?: number,
- siteId?: JiraSiteSelection | null
+ siteId?: JiraSiteSelection | null,
+ filters?: JiraIssueListFilters
): Promise {
- const target = getJiraRuntimeTarget(settings)
- const args = { filter, limit, siteId: siteId ?? undefined }
- return target.kind === 'environment'
- ? callRuntimeRpc(target, 'jira.listIssues', args, { timeoutMs: 30_000 })
- : window.api.jira.listIssues(args)
+ return jiraListIssuesForTarget(getJiraRuntimeTarget(settings), filter, limit, siteId, filters)
+}
+
+export async function jiraListIssuesForTarget(
+ target: RuntimeClientTarget,
+ filter?: JiraIssueFilter,
+ limit?: number,
+ siteId?: JiraSiteSelection | null,
+ filters?: JiraIssueListFilters
+): Promise {
+ const args = { filter, limit, siteId: siteId ?? undefined, ...(filters ? { filters } : {}) }
+ if (target.kind === 'environment') {
+ if (filters?.statuses?.length) {
+ await assertRuntimeEnvironmentCapability(
+ target.environmentId,
+ JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
+ JIRA_STATUS_FILTER_UPDATE_REQUIRED,
+ 15_000
+ )
+ }
+ return callRuntimeRpc(target, 'jira.listIssues', args, { timeoutMs: 30_000 })
+ }
+ return window.api.jira.listIssues(args)
}
export async function jiraGetIssue(
diff --git a/src/renderer/src/runtime/runtime-jira-quick-filter-state.ts b/src/renderer/src/runtime/runtime-jira-quick-filter-state.ts
new file mode 100644
index 00000000000..589aae4541e
--- /dev/null
+++ b/src/renderer/src/runtime/runtime-jira-quick-filter-state.ts
@@ -0,0 +1,49 @@
+import {
+ JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
+ JIRA_STATUS_FILTER_UPDATE_REQUIRED,
+ normalizeJiraQuickFilterState,
+ type JiraQuickFilterState
+} from '../../../shared/jira-quick-filters'
+import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
+import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
+
+export async function readJiraQuickFilterState(
+ target: RuntimeClientTarget
+): Promise {
+ if (target.kind === 'environment') {
+ await assertRuntimeEnvironmentCapability(
+ target.environmentId,
+ JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
+ JIRA_STATUS_FILTER_UPDATE_REQUIRED,
+ 15_000
+ )
+ return normalizeJiraQuickFilterState(
+ await callRuntimeRpc(target, 'ui.getJiraQuickFilters', undefined, { timeoutMs: 15_000 })
+ )
+ }
+ if (!window.api.ui.getJiraQuickFilters) {
+ throw new Error(JIRA_STATUS_FILTER_UPDATE_REQUIRED)
+ }
+ return normalizeJiraQuickFilterState(await window.api.ui.getJiraQuickFilters())
+}
+
+export async function writeJiraQuickFilterState(
+ target: RuntimeClientTarget,
+ state: JiraQuickFilterState
+): Promise {
+ if (target.kind === 'environment') {
+ await assertRuntimeEnvironmentCapability(
+ target.environmentId,
+ JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
+ JIRA_STATUS_FILTER_UPDATE_REQUIRED,
+ 15_000
+ )
+ return normalizeJiraQuickFilterState(
+ await callRuntimeRpc(target, 'ui.setJiraQuickFilters', state, { timeoutMs: 15_000 })
+ )
+ }
+ if (!window.api.ui.setJiraQuickFilters) {
+ throw new Error(JIRA_STATUS_FILTER_UPDATE_REQUIRED)
+ }
+ return normalizeJiraQuickFilterState(await window.api.ui.setJiraQuickFilters(state))
+}
diff --git a/src/renderer/src/runtime/runtime-jira-status-filters.test.ts b/src/renderer/src/runtime/runtime-jira-status-filters.test.ts
new file mode 100644
index 00000000000..8d98c505a7f
--- /dev/null
+++ b/src/renderer/src/runtime/runtime-jira-status-filters.test.ts
@@ -0,0 +1,84 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { jiraListIssues, jiraListIssuesForTarget } from './runtime-jira-client'
+import {
+ readJiraQuickFilterState,
+ writeJiraQuickFilterState
+} from './runtime-jira-quick-filter-state'
+import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from '../../../shared/jira-quick-filters'
+const { assertCapability, rpc } = vi.hoisted(() => ({ assertCapability: vi.fn(), rpc: vi.fn() }))
+vi.mock('./runtime-rpc-client', () => ({
+ assertRuntimeEnvironmentCapability: assertCapability,
+ callRuntimeRpc: rpc
+}))
+beforeEach(() => {
+ vi.resetAllMocks()
+})
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+const target = { kind: 'environment', environmentId: 'jira-owner' } as const
+it('keeps the old request shape when no statuses are selected', async () => {
+ rpc.mockResolvedValue([])
+ await jiraListIssues(target, 'all', 50, 'site-a')
+ expect(assertCapability).not.toHaveBeenCalled()
+ expect(rpc).toHaveBeenCalledWith(
+ target,
+ 'jira.listIssues',
+ { filter: 'all', limit: 50, siteId: 'site-a' },
+ { timeoutMs: 30_000 }
+ )
+})
+it('gates status list filters on the called execution host', async () => {
+ rpc.mockResolvedValue([])
+ await jiraListIssuesForTarget(target, 'all', 50, 'site-a', { statuses: ['In Progress'] })
+ expect(assertCapability).toHaveBeenCalledWith(
+ 'jira-owner',
+ JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
+ expect.any(String),
+ 15_000
+ )
+ expect(rpc).toHaveBeenCalledWith(
+ target,
+ 'jira.listIssues',
+ { filter: 'all', limit: 50, siteId: 'site-a', filters: { statuses: ['In Progress'] } },
+ { timeoutMs: 30_000 }
+ )
+})
+it('refuses an older host without silently dropping selection or writing strict resume fields', async () => {
+ assertCapability.mockRejectedValue(new Error('Update the Jira host'))
+ await expect(
+ jiraListIssues(target, 'all', 50, 'site-a', { statuses: ['Blocked'] })
+ ).rejects.toThrow('Update')
+ await expect(writeJiraQuickFilterState(target, { statuses: ['Blocked'] })).rejects.toThrow(
+ 'Update'
+ )
+ expect(rpc).not.toHaveBeenCalled()
+})
+it('routes persisted state through host UI methods and acknowledges the write', async () => {
+ rpc.mockResolvedValue({ statuses: ['Blocked'] })
+ expect(await readJiraQuickFilterState(target)).toEqual({ statuses: ['Blocked'] })
+ expect(await writeJiraQuickFilterState(target, { statuses: ['Blocked'] })).toEqual({
+ statuses: ['Blocked']
+ })
+ expect(rpc).toHaveBeenLastCalledWith(
+ target,
+ 'ui.setJiraQuickFilters',
+ { statuses: ['Blocked'] },
+ { timeoutMs: 15_000 }
+ )
+})
+it('uses local IPC and reports a lost write instead of pretending to save', async () => {
+ const read = vi.fn(async () => ({ statuses: ['Open'] }))
+ const write = vi.fn(async () => {
+ throw new Error('Lost host')
+ })
+ vi.stubGlobal('window', {
+ api: { ui: { getJiraQuickFilters: read, setJiraQuickFilters: write } }
+ })
+ expect(await readJiraQuickFilterState({ kind: 'local' })).toEqual({ statuses: ['Open'] })
+ await expect(writeJiraQuickFilterState({ kind: 'local' }, { statuses: [] })).rejects.toThrow(
+ 'Lost host'
+ )
+ expect(rpc).not.toHaveBeenCalled()
+})
diff --git a/src/renderer/src/store/slices/jira-collection-read-actions.ts b/src/renderer/src/store/slices/jira-collection-read-actions.ts
index b79d358cea5..fa1f6b5836d 100644
--- a/src/renderer/src/store/slices/jira-collection-read-actions.ts
+++ b/src/renderer/src/store/slices/jira-collection-read-actions.ts
@@ -1,4 +1,6 @@
-import { jiraListIssues, jiraSearchIssues } from '@/runtime/runtime-jira-client'
+import { normalizeJiraStatusSelection } from '../../../../shared/jira-quick-filters'
+import { jiraListIssuesForTarget, jiraSearchIssues } from '@/runtime/runtime-jira-client'
+import { getJiraRuntimeTarget } from '@/runtime/runtime-jira-target'
import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors'
import type { JiraIssue, JiraSiteSelection } from '../../../../shared/jira-types'
import type { JiraSlice, JiraSliceGet, JiraSliceSet } from './jira-slice-contract'
@@ -148,9 +150,11 @@ export function createJiraCollectionReadActions(
listJiraIssues: async (filter = 'assigned', limit = 30, options) => {
const scope = getJiraReadScope(get().settings, options?.sourceContext)
const siteId = getSelectedJiraSiteId(get().jiraStatus)
+ const statuses = normalizeJiraStatusSelection(options?.filters?.statuses).sort()
+ const filters = statuses.length ? { statuses } : undefined
const cacheKey = scopedJiraCacheKey(
scope,
- `${siteId ?? 'default'}::list::${filter}::${limit}`
+ `${siteId ?? 'default'}::list::${filter}::${limit}${filters ? `::${JSON.stringify(filters)}` : ''}`
)
const cached = get().jiraSearchCache[cacheKey]
if (!options?.force && isFreshJiraCacheEntry(cached)) {
@@ -167,7 +171,14 @@ export function createJiraCollectionReadActions(
return inflight.promise
}
let entry: InflightJiraReadRequest
- const promise = jiraListIssues(scope.settings, filter, limit, siteId)
+ const request = jiraListIssuesForTarget(
+ getJiraRuntimeTarget(scope.settings),
+ filter,
+ limit,
+ siteId,
+ filters
+ )
+ const promise = request
.then((issues) => {
if (
inflightListRequests.get(cacheKey) === entry &&
diff --git a/src/renderer/src/store/slices/jira-slice-contract.ts b/src/renderer/src/store/slices/jira-slice-contract.ts
index 88492f8e0cc..66e80aac7af 100644
--- a/src/renderer/src/store/slices/jira-slice-contract.ts
+++ b/src/renderer/src/store/slices/jira-slice-contract.ts
@@ -6,6 +6,7 @@ import type {
JiraConnectionStatus,
JiraIssue,
JiraIssueFilter,
+ JiraIssueListFilters,
JiraSiteSelection,
JiraViewer
} from '../../../../shared/jira-types'
@@ -16,7 +17,10 @@ export type JiraReadOptions = {
siteId?: JiraSiteSelection | null
}
-export type JiraCollectionReadOptions = JiraReadOptions & { force?: boolean }
+export type JiraCollectionReadOptions = JiraReadOptions & {
+ force?: boolean
+ filters?: JiraIssueListFilters
+}
export type JiraSearchOptions = JiraCollectionReadOptions & { signal?: AbortSignal }
export type JiraPatchOptions = {
sourceContext?: TaskSourceContext | null
diff --git a/src/renderer/src/store/slices/jira-status-filter-cache.test.ts b/src/renderer/src/store/slices/jira-status-filter-cache.test.ts
new file mode 100644
index 00000000000..0f887087d8f
--- /dev/null
+++ b/src/renderer/src/store/slices/jira-status-filter-cache.test.ts
@@ -0,0 +1,53 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { create } from 'zustand'
+import type { AppState } from '../types'
+import type { JiraIssue, JiraIssueListFilters } from '../../../../shared/jira-types'
+import { createJiraSlice } from './jira'
+const { list } = vi.hoisted(() => ({ list: vi.fn() }))
+vi.mock('@/runtime/runtime-jira-client', () => ({
+ jiraListIssues: list,
+ jiraListIssuesForTarget: list,
+ jiraSearchIssues: vi.fn()
+}))
+beforeEach(() => {
+ vi.resetAllMocks()
+})
+function issue(key: string): JiraIssue {
+ return {
+ id: key,
+ key,
+ title: key,
+ url: `https://jira.example/browse/${key}`,
+ project: { id: 'project', key: 'APP', name: 'App' },
+ issueType: { id: 'bug', name: 'Bug' },
+ status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'To Do' },
+ labels: [],
+ createdAt: '2026-10-01',
+ updatedAt: '2026-10-01'
+ }
+}
+it('isolates status results from preset caches and reuses an equivalent selection order', async () => {
+ const store = create()(
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only settings and Jira slice fields are accessed by these list reads.
+ (...args) => ({ settings: null, ...createJiraSlice(...args) }) as AppState
+ )
+ list.mockImplementation(
+ async (_source, _preset, _limit, _site, filters?: JiraIssueListFilters) => [
+ issue(filters?.statuses?.join('-') || 'default')
+ ]
+ )
+ expect(await store.getState().listJiraIssues('all', 50)).toMatchObject([{ key: 'default' }])
+ expect(
+ await store.getState().listJiraIssues('all', 50, { filters: { statuses: ['Open'] } })
+ ).toMatchObject([{ key: 'Open' }])
+ expect(await store.getState().listJiraIssues('all', 50)).toMatchObject([{ key: 'default' }])
+ expect(
+ await store
+ .getState()
+ .listJiraIssues('all', 50, { filters: { statuses: ['In Progress', 'Blocked'] } })
+ ).toMatchObject([{ key: 'Blocked-In Progress' }])
+ await store
+ .getState()
+ .listJiraIssues('all', 50, { filters: { statuses: ['Blocked', 'In Progress'] } })
+ expect(list).toHaveBeenCalledTimes(3)
+})
diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts
index 0867b1e3045..1e7ec42a3ff 100644
--- a/src/renderer/src/store/slices/jira.test.ts
+++ b/src/renderer/src/store/slices/jira.test.ts
@@ -34,6 +34,7 @@ vi.mock('@/runtime/runtime-jira-client', () => ({
jiraListCreateFields: vi.fn(),
jiraListIssueTypes: vi.fn(),
jiraListIssues: (...args: unknown[]) => jiraListIssues(...args),
+ jiraListIssuesForTarget: (...args: unknown[]) => jiraListIssues(...args),
jiraReadStatus: (...args: unknown[]) => jiraReadStatus(...args),
jiraListPriorities: vi.fn(),
jiraListProjects: vi.fn(),
@@ -208,7 +209,13 @@ describe('createJiraSlice runtime context', () => {
sourceResult.resolve([{ ...issue('ALP-1'), title: 'Source issue' }])
await expect(request).resolves.toMatchObject([{ key: 'ALP-1', title: 'Source issue' }])
- expect(jiraListIssues).toHaveBeenCalledWith(sourceContext, 'assigned', 30, 'site-1')
+ expect(jiraListIssues).toHaveBeenCalledWith(
+ { kind: 'environment', environmentId: 'source-runtime' },
+ 'assigned',
+ 30,
+ 'site-1',
+ undefined
+ )
expect(Object.values(store.getState().jiraSearchCache)).toHaveLength(1)
expect(store.getState().jiraSearchCache['site-1::list::assigned::30']).toBeUndefined()
})
diff --git a/src/shared/jira-detail-capabilities.ts b/src/shared/jira-detail-capabilities.ts
new file mode 100644
index 00000000000..6b1d3852a9a
--- /dev/null
+++ b/src/shared/jira-detail-capabilities.ts
@@ -0,0 +1,4 @@
+export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
+export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
+export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
+ 'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
diff --git a/src/shared/jira-preset-jql.test.ts b/src/shared/jira-preset-jql.test.ts
new file mode 100644
index 00000000000..28970b38c82
--- /dev/null
+++ b/src/shared/jira-preset-jql.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import { buildJiraPresetJql } from './jira-preset-jql'
+import { normalizeJiraQuickFilterState } from './jira-quick-filters'
+import { JiraIssueListFiltersParam } from './rpc-contract/jira-quick-filter-params'
+
+describe('Jira preset status predicates', () => {
+ it.each([
+ ['assigned', 'assignee = currentUser() AND statusCategory != Done'],
+ ['reported', 'reporter = currentUser() AND statusCategory != Done'],
+ ['all', 'statusCategory != Done'],
+ ['done', 'assignee = currentUser() AND statusCategory = Done']
+ ] as const)('preserves %s scope and combines statuses with OR', (preset, base) => {
+ expect(buildJiraPresetJql(preset)).toBe(`${base} ORDER BY updated DESC`)
+ expect(buildJiraPresetJql(preset, { statuses: ['Blocked', 'In Progress'] })).toBe(
+ `${base} AND status in ("Blocked", "In Progress") ORDER BY updated DESC`
+ )
+ })
+ it('escapes quoted status names without treating ORDER BY as syntax', () => {
+ expect(
+ buildJiraPresetJql('all', { statuses: ['Needs "QA" \\ Review', 'ORDER BY cleanup'] })
+ ).toBe(
+ 'statusCategory != Done AND status in ("Needs \\"QA\\" \\\\ Review", "ORDER BY cleanup") ORDER BY updated DESC'
+ )
+ })
+ it('bounds persisted state while preserving actual status names', () => {
+ expect(
+ normalizeJiraQuickFilterState({
+ statuses: [' In Progress ', 'Done', 'Done', 42, '', 'x'.repeat(129)]
+ })
+ ).toEqual({ statuses: [' In Progress ', 'Done'] })
+ expect(normalizeJiraQuickFilterState(null)).toEqual({ statuses: [] })
+ expect(
+ JiraIssueListFiltersParam.safeParse({ statuses: Array.from({ length: 51 }, () => 'Open') })
+ .success
+ ).toBe(false)
+ expect(JiraIssueListFiltersParam.safeParse({ statuses: [' '] }).success).toBe(false)
+ })
+})
diff --git a/src/shared/jira-preset-jql.ts b/src/shared/jira-preset-jql.ts
index bc01d439139..b59de9efd00 100644
--- a/src/shared/jira-preset-jql.ts
+++ b/src/shared/jira-preset-jql.ts
@@ -1,14 +1,21 @@
-import type { JiraIssueFilter } from './jira-types'
+import type { JiraIssueFilter, JiraIssueListFilters } from './jira-types'
+import { normalizeJiraStatusSelection } from './jira-quick-filters'
-export function buildJiraPresetJql(filter: JiraIssueFilter): string {
- if (filter === 'assigned') {
- return 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC'
+export function buildJiraPresetJql(
+ filter: JiraIssueFilter,
+ filters?: JiraIssueListFilters
+): string {
+ const clauses: string[] = []
+ if (filter === 'assigned' || filter === 'done') {
+ clauses.push('assignee = currentUser()')
+ } else if (filter === 'reported') {
+ clauses.push('reporter = currentUser()')
}
- if (filter === 'reported') {
- return 'reporter = currentUser() AND statusCategory != Done ORDER BY updated DESC'
+ clauses.push(filter === 'done' ? 'statusCategory = Done' : 'statusCategory != Done')
+ const statuses = normalizeJiraStatusSelection(filters?.statuses)
+ if (statuses.length > 0) {
+ const names = statuses.map((name) => `"${name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
+ clauses.push(`status in (${names.join(', ')})`)
}
- if (filter === 'done') {
- return 'assignee = currentUser() AND statusCategory = Done ORDER BY updated DESC'
- }
- return 'statusCategory != Done ORDER BY updated DESC'
+ return `${clauses.join(' AND ')} ORDER BY updated DESC`
}
diff --git a/src/shared/jira-quick-filters.ts b/src/shared/jira-quick-filters.ts
new file mode 100644
index 00000000000..93a9dc5db9f
--- /dev/null
+++ b/src/shared/jira-quick-filters.ts
@@ -0,0 +1,30 @@
+export const JIRA_STATUS_FILTER_RUNTIME_CAPABILITY = 'jira.issue-list-statuses.v1' as const
+export const JIRA_STATUS_FILTER_UPDATE_REQUIRED = 'Update the Jira host to use status filters.'
+export const MAX_JIRA_STATUS_FILTERS = 50
+export const MAX_JIRA_STATUS_NAME_LENGTH = 128
+
+export type JiraQuickFilterState = { statuses: string[] }
+
+export function normalizeJiraStatusSelection(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+ return [
+ ...new Set(
+ value.filter(
+ (item): item is string =>
+ typeof item === 'string' &&
+ item.trim().length > 0 &&
+ item.length <= MAX_JIRA_STATUS_NAME_LENGTH
+ )
+ )
+ ].slice(0, MAX_JIRA_STATUS_FILTERS)
+}
+
+export function normalizeJiraQuickFilterState(value: unknown): JiraQuickFilterState {
+ return {
+ statuses: normalizeJiraStatusSelection(
+ value && typeof value === 'object' && 'statuses' in value ? value.statuses : undefined
+ )
+ }
+}
diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts
index df768f99291..abd05bed47c 100644
--- a/src/shared/jira-types.ts
+++ b/src/shared/jira-types.ts
@@ -149,6 +149,8 @@ export type JiraIssueUpdate = {
export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
+export type JiraIssueListFilters = { statuses?: string[] }
+
export type JiraConnectArgs = {
siteUrl: string
// Empty for Bearer authentication: self-hosted PAT or scoped Cloud token.
diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts
index 1a7c5f59a38..8aeeaf36139 100644
--- a/src/shared/persisted-ui-state-types.ts
+++ b/src/shared/persisted-ui-state-types.ts
@@ -1,3 +1,4 @@
+import type { JiraQuickFilterState } from './jira-quick-filters'
import type { JiraSavedFilterState } from './jira-saved-filters'
import type { ReleaseChannel } from './release-channel'
import type { WorkspaceCleanupUIState } from './workspace-cleanup'
@@ -29,6 +30,7 @@ import type { PersistedAutomationHostFilter } from './automation-host-filter'
export type PersistedUIState = {
jiraSavedFilterState?: JiraSavedFilterState
+ jiraQuickFilterState?: JiraQuickFilterState
lastActiveRepoId: string | null
lastActiveWorktreeId: string | null
/** Active top-level view at save time, restored on relaunch; sanitized to 'terminal' if unknown or now-gated. */
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index a1636f4f72e..c31ed7cfd12 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -1,5 +1,8 @@
import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability'
+import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters'
import { JIRA_SAVED_FILTER_RUNTIME_CAPABILITY } from './jira-saved-filter-capability'
+import * as jiraDetailCapabilities from './jira-detail-capabilities'
+export * from './jira-detail-capabilities'
import { WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES } from './workspace-attachment-capabilities'
import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from './ssh-target-management'
import {
@@ -103,11 +106,6 @@ export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY =
'folder-workspace.path-status.v1' as const
export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY =
'linear.issue-attribute-filter.v1' as const
-export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
-
-export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
-export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
- 'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
// Why: signals the host exposes the Agent Session History scanner over RPC
// (aiVault.listSessions). Registered unconditionally for every build, so it is a
// STATIC capability advertised by getStatus() automatically — NOT a runtime
@@ -388,9 +386,10 @@ export const RUNTIME_CAPABILITIES = [
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
- JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
- JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
+ jiraDetailCapabilities.JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
+ jiraDetailCapabilities.JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
+ JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
AI_VAULT_RUNTIME_CAPABILITY,
AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY,
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
diff --git a/src/shared/rpc-contract/jira-params.ts b/src/shared/rpc-contract/jira-params.ts
index b37863bb5e8..b70f75c6b15 100644
--- a/src/shared/rpc-contract/jira-params.ts
+++ b/src/shared/rpc-contract/jira-params.ts
@@ -1,3 +1,4 @@
+import { JiraIssueListFiltersParam } from './jira-quick-filter-params'
import { z } from 'zod'
import {
OptionalFiniteNumber,
@@ -39,7 +40,8 @@ export const ListIssues = z
filter: z.enum(VALID_FILTERS).optional(),
limit: OptionalFiniteNumber,
siteId: OptionalString,
- strict: z.boolean().optional()
+ strict: z.boolean().optional(),
+ filters: JiraIssueListFiltersParam.optional()
})
.optional()
diff --git a/src/shared/rpc-contract/jira-quick-filter-params.ts b/src/shared/rpc-contract/jira-quick-filter-params.ts
new file mode 100644
index 00000000000..51222f00725
--- /dev/null
+++ b/src/shared/rpc-contract/jira-quick-filter-params.ts
@@ -0,0 +1,15 @@
+import { z } from 'zod'
+import { MAX_JIRA_STATUS_FILTERS, MAX_JIRA_STATUS_NAME_LENGTH } from '../jira-quick-filters'
+
+const JiraStatuses = z
+ .array(
+ z
+ .string()
+ .min(1)
+ .max(MAX_JIRA_STATUS_NAME_LENGTH)
+ .refine((name) => name.trim().length > 0)
+ )
+ .max(MAX_JIRA_STATUS_FILTERS)
+
+export const JiraQuickFilterStateParam = z.strictObject({ statuses: JiraStatuses })
+export const JiraIssueListFiltersParam = z.strictObject({ statuses: JiraStatuses.optional() })
diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
index 6a112381baa..69d35eee193 100644
--- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts
+++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
@@ -332,6 +332,7 @@ import {
SiteSelection,
UserSearch
} from './jira-params'
+import { JiraQuickFilterStateParam } from './jira-quick-filter-params'
import { JiraSavedFilterMutationParam } from './jira-saved-filter-params'
import {
AgentIssueContext,
@@ -1279,10 +1280,12 @@ export const RPC_PARAMS_BY_METHOD = {
'terminalDaemon.restartSevered': TerminalDaemonRestartSeveredParams,
'terminalDaemon.severedStatus': null,
'ui.get': null,
+ 'ui.getJiraQuickFilters': null,
'ui.getJiraSavedFilters': null,
'ui.mutateJiraSavedFilters': JiraSavedFilterMutationParam,
'ui.recordFeatureInteraction': FeatureInteractionIdParam,
'ui.set': UiUpdate,
+ 'ui.setJiraQuickFilters': JiraQuickFilterStateParam,
'updater.check': UpdaterCheckParams,
'updater.download': null,
'updater.getStatus': null,
diff --git a/src/shared/wsl-move/wsl-move-ui-dispositions.ts b/src/shared/wsl-move/wsl-move-ui-dispositions.ts
index a30cea32c31..352e5092807 100644
--- a/src/shared/wsl-move/wsl-move-ui-dispositions.ts
+++ b/src/shared/wsl-move/wsl-move-ui-dispositions.ts
@@ -64,6 +64,7 @@ export const UI_STATE_DISPOSITIONS = {
item: 38,
note: "A 'local' filter also covers the WSL host."
},
+ jiraQuickFilterState: notApplicableDisposition(38, 'Filters belong to the Jira host.'),
jiraSavedFilterState: notApplicableDisposition(
38,
'Filters belong to the Jira host, not a moved workspace.'