Search connected tasks by keyword from Cmd+J (#28617)

Search Jira tasks from Cmd+J with captured execution-owner routing and stale-request protection.
This commit is contained in:
Neil
2026-10-14 03:16:03 -07:00
committed by GitHub
parent 5277fb4422
commit 3bf9c85993
26 changed files with 1679 additions and 38 deletions
@@ -0,0 +1,184 @@
// @vitest-environment happy-dom
import { act, forwardRef, type ReactNode } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
import { jiraTaskIssue as issue } from './cmd-j/task-search.test-fixture'
import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures'
import type * as ReactI18next from 'react-i18next'
import type * as RuntimeJiraClient from '@/runtime/runtime-jira-client'
import { jiraSearchIssuesForTarget, jiraStatusForTarget } from '@/runtime/runtime-jira-client'
import WorktreeJumpPalette from './WorktreeJumpPalette'
vi.mock('@/runtime/runtime-jira-client', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeJiraClient>()),
jiraStatusForTarget: vi.fn(),
jiraSearchIssuesForTarget: vi.fn()
}))
vi.mock('@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover', () => ({
WorkspaceEmojiSuggestionPopover: () => null
}))
vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({
useSettingsNavigationMetadata: () => []
}))
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof ReactI18next>()),
useTranslation: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/components/ui/command', () => ({
CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
open ? <div>{children}</div> : null,
CommandInput: forwardRef<
HTMLInputElement,
{ value: string; onValueChange: (value: string) => void }
>(function Input({ value, onValueChange }, ref) {
return (
<input
ref={ref}
aria-label="Palette search"
value={value}
onChange={(e) => onValueChange(e.target.value)}
/>
)
}),
CommandList: forwardRef<HTMLDivElement, { children: ReactNode }>(function List(
{ children },
ref
) {
return <div ref={ref}>{children}</div>
}),
CommandEmpty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
CommandItem: ({
children,
onSelect,
value
}: {
children: ReactNode
onSelect: () => void
value: string
}) => (
<button cmdk-item="" data-value={value} onClick={onSelect}>
{children}
</button>
)
}))
const initial = useAppStore.getInitialState()
const search = vi.mocked(jiraSearchIssuesForTarget)
const openTaskPage = vi.fn()
beforeEach(() => {
useAppStore.setState(initial, true)
search.mockReset().mockResolvedValue([issue])
vi.mocked(jiraStatusForTarget)
.mockReset()
.mockResolvedValue({
connected: true,
viewer: null,
selectedSiteId: 'all',
sites: [
{
id: 'other-site',
siteUrl: 'https://other.atlassian.net',
email: 'other@example.com',
displayName: 'Other Jira',
accountId: 'other'
}
]
})
openTaskPage.mockClear()
useAppStore.setState({
activeModal: 'worktree-palette',
repos: [],
worktreesByRepo: {},
settings: createGlobalSettingsFixture({ visibleTaskProviders: ['jira'] }),
openTaskPage
})
})
afterEach(() => {
cleanup()
useAppStore.setState(initial, true)
})
it('finds an unlinked Jira task from keywords and opens detail with the returned site/account identity', async () => {
render(<WorktreeJumpPalette />)
fireEvent.change(screen.getByLabelText('Palette search'), { target: { value: 'login redirect' } })
await waitFor(() => expect(screen.queryByText('Fix login redirect')).not.toBeNull(), {
timeout: 2000
})
expect(screen.getByText('Tasks')).toBeTruthy()
expect(search).toHaveBeenCalledWith(
{ kind: 'local' },
'text ~ "login redirect*"',
expect.any(Number),
'all',
expect.any(AbortSignal)
)
await act(async () => {
fireEvent.click(screen.getByText('Fix login redirect'))
})
expect(openTaskPage).toHaveBeenCalledWith(
expect.objectContaining({
taskSource: 'jira',
openJiraIssue: issue,
openJiraSourceContext: expect.objectContaining({
hostId: 'local',
accountLabel: 'other@example.com',
providerIdentity: expect.objectContaining({
siteId: 'other-site',
siteUrl: 'https://other.atlassian.net'
})
})
}),
expect.anything()
)
})
it('shows local matches while a provider request is still pending', async () => {
search.mockImplementation(() => new Promise(() => {}))
useAppStore.setState({
repos: [makeRepo()],
worktreesByRepo: { 'repo-1': [makeWorktree('local', 'login redirect workspace')] }
})
render(<WorktreeJumpPalette />)
fireEvent.change(screen.getByLabelText('Palette search'), { target: { value: 'login redirect' } })
expect(screen.getByRole('button', { name: /login redirect workspace/ })).toBeTruthy()
await waitFor(() => expect(search).toHaveBeenCalled())
expect(screen.getByRole('button', { name: /login redirect workspace/ })).toBeTruthy()
})
it('discards a previous query response and aborts its Jira request', async () => {
let complete: (value: (typeof issue)[]) => void = () => {}
search.mockImplementationOnce(
() =>
new Promise<(typeof issue)[]>((resolve) => {
complete = resolve
})
)
render(<WorktreeJumpPalette />)
fireEvent.change(screen.getByLabelText('Palette search'), { target: { value: 'old query' } })
await waitFor(() => expect(search).toHaveBeenCalledTimes(1))
const firstSignal = search.mock.calls[0]?.[4]
search.mockResolvedValueOnce([{ ...issue, title: 'Current query result' }])
fireEvent.change(screen.getByLabelText('Palette search'), { target: { value: 'current query' } })
await waitFor(() => expect(screen.queryByText('Current query result')).not.toBeNull())
await act(async () => complete([issue]))
expect(firstSignal?.aborted).toBe(true)
expect(screen.queryByText('Fix login redirect')).toBeNull()
})
it('keeps local results and offers a retry after a provider failure', async () => {
search.mockRejectedValueOnce(new Error('Offline'))
useAppStore.setState({
repos: [makeRepo()],
worktreesByRepo: { 'repo-1': [makeWorktree('local', 'login redirect workspace')] }
})
render(<WorktreeJumpPalette />)
fireEvent.change(screen.getByLabelText('Palette search'), { target: { value: 'login redirect' } })
await waitFor(() =>
expect(screen.queryByText(/Some task sources could not be searched/)).not.toBeNull()
)
expect(screen.getByRole('button', { name: /login redirect workspace/ })).toBeTruthy()
fireEvent.click(screen.getByText('Retry'))
await waitFor(() => expect(screen.queryByText('Fix login redirect')).not.toBeNull())
})
@@ -0,0 +1,99 @@
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { findCmdJTaskWorkspace, openCmdJTask } from './task-search-activation'
import { jiraTaskIssue, jiraTaskSource, jiraTaskResult } from './task-search.test-fixture'
import { makeWorktree, makeFolderWorkspace } from '../worktree-jump-palette-test-fixtures'
import { getFolderWorkspaceWorktree } from '@/store/slices/worktrees/listing/detected-worktree-meta'
const initial = useAppStore.getInitialState()
afterEach(() => useAppStore.setState(initial, true))
const attachment = {
provider: 'jira' as const,
type: 'issue' as const,
number: 0,
url: jiraTaskIssue.url,
jiraIdentifier: jiraTaskIssue.key,
taskSourceContext: jiraTaskSource
}
it('uses the attachment source owner when a workspace executes on a different host', () => {
const workspace = makeWorktree('remote', 'Jira workspace', {
hostId: 'runtime:runner',
linkedItems: [attachment]
})
expect(findCmdJTaskWorkspace(jiraTaskResult, [workspace])).toBe(workspace)
})
it('rejects source host/account/site collisions even when the issue URL matches', () => {
for (const source of [
{ ...jiraTaskSource, hostId: 'runtime:other' as const },
{ ...jiraTaskSource, accountLabel: 'someone@example.com' },
{
...jiraTaskSource,
providerIdentity: {
provider: 'jira' as const,
siteId: 'different-site',
siteUrl: 'https://other.atlassian.net',
projectKey: 'DEMO'
}
}
]) {
const workspace = makeWorktree('wrong', 'Wrong source', {
linkedItems: [{ ...attachment, taskSourceContext: source }]
})
expect(findCmdJTaskWorkspace(jiraTaskResult, [workspace])).toBeUndefined()
}
})
it('rejects bare legacy identifiers and other Jira origins', () => {
const bare = makeWorktree('bare', 'Unscoped', {
linkedWorkItem: {
provider: 'jira',
type: 'issue',
number: 0,
title: '',
url: '',
jiraIdentifier: jiraTaskIssue.key
}
})
const wrongSite = makeWorktree('wrong', 'Other site', {
linkedItems: [{ ...attachment, url: 'https://wrong.atlassian.net/browse/DEMO-12' }]
})
expect(findCmdJTaskWorkspace(jiraTaskResult, [bare, wrongSite])).toBeUndefined()
})
it('matches linked folder workspaces and excludes archives', () => {
const folder = getFolderWorkspaceWorktree(
makeFolderWorkspace({
linkedTask: {
provider: 'jira',
type: 'issue',
number: 0,
title: jiraTaskIssue.title,
url: jiraTaskIssue.url,
jiraIdentifier: jiraTaskIssue.key
},
linkedTaskSourceContext: jiraTaskSource
})
)
expect(findCmdJTaskWorkspace(jiraTaskResult, [folder])).toBe(folder)
expect(findCmdJTaskWorkspace(jiraTaskResult, [{ ...folder, isArchived: true }])).toBeUndefined()
})
it('opens the existing composer with the exact task source and Jira identifier', async () => {
const openModal = vi.fn()
useAppStore.setState({ openModal, closeModal: vi.fn() })
openCmdJTask(jiraTaskResult, true)
await Promise.resolve()
expect(openModal).toHaveBeenCalledWith(
'new-workspace-composer',
expect.objectContaining({
linkedWorkItem: expect.objectContaining({
jiraIdentifier: jiraTaskIssue.key,
url: jiraTaskIssue.url
}),
taskSourceContext: jiraTaskSource,
telemetrySource: 'command_palette'
})
)
})
@@ -0,0 +1,140 @@
import { useAppStore } from '@/store'
import { getPaletteWorktreeExecutionHostId } from '@/lib/palette-repo-resolution'
import { getWorkspaceAttachments } from '../../../../shared/workspace-attachments'
import {
getProvenWorkspaceReferenceIdentity,
parseWorkspaceReferenceUrl
} from '../../../../shared/workspace-reference-identity'
import { getLinearIssueWorkspaceName } from '../../../../shared/workspace-name'
import {
getLinkedWorkItemWorkspaceName,
getLinkedWorkItemSuggestedName,
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
import type { Worktree } from '../../../../shared/worktree/types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { CmdJTaskSearchResult } from './task-search-results'
import { getJiraIssueWorkspaceSeed } from '../task-page-source-context'
function sourceMatches(source: TaskSourceContext, target: TaskSourceContext): boolean {
if (source.provider !== target.provider || source.hostId !== target.hostId) {
return false
}
if (source.accountLabel && target.accountLabel && source.accountLabel !== target.accountLabel) {
return false
}
const left = source.providerIdentity
const right = target.providerIdentity
if (
left?.provider === 'linear' &&
right?.provider === 'linear' &&
left.workspaceId &&
right.workspaceId &&
left.workspaceId !== right.workspaceId
) {
return false
}
if (
left?.provider === 'jira' &&
right?.provider === 'jira' &&
left.siteId &&
right.siteId &&
left.siteId !== right.siteId
) {
return false
}
return true
}
export function findCmdJTaskWorkspace(
result: CmdJTaskSearchResult,
workspaces: readonly Worktree[]
): Worktree | undefined {
let identity: string | undefined
try {
identity = getProvenWorkspaceReferenceIdentity(
parseWorkspaceReferenceUrl(result.task.issue.url, result.task.provider)
)
} catch {
return undefined
}
if (!identity) {
return undefined
}
return workspaces
.filter((workspace) => !workspace.isArchived)
.find((workspace) =>
getWorkspaceAttachments(workspace).some((item) => {
if (
item.provider !== result.task.provider ||
getProvenWorkspaceReferenceIdentity(item) !== identity
) {
return false
}
// Legacy links have no source owner, so only their own execution host is safe.
return item.taskSourceContext
? sourceMatches(item.taskSourceContext, result.sourceContext)
: (getPaletteWorktreeExecutionHostId(workspace) ?? 'local') ===
result.sourceContext.hostId
})
)
}
export function openCmdJTask(result: CmdJTaskSearchResult, createWorkspace = false): void {
const state = useAppStore.getState()
state.closeModal()
const task = result.task
if (createWorkspace) {
const linkedWorkItem: LinkedWorkItemSummary =
task.provider === 'linear'
? buildLinearIssueLinkedWorkItem(task.issue)
: {
provider: task.provider,
type: 'issue',
number: task.provider === 'jira' ? 0 : task.issue.number,
title: task.issue.title,
url: task.issue.url,
...(task.provider === 'jira' ? { jiraIdentifier: task.issue.key } : {})
}
const prefilledName =
task.provider === 'linear'
? getLinearIssueWorkspaceName(task.issue)
: task.provider === 'jira'
? getJiraIssueWorkspaceSeed(task.issue)
: (getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ??
getLinkedWorkItemSuggestedName(linkedWorkItem))
queueMicrotask(() =>
useAppStore.getState().openModal('new-workspace-composer', {
linkedWorkItem,
prefilledName,
initialRepoId: result.initialRepoId,
taskSourceContext: result.sourceContext,
telemetrySource: 'command_palette'
})
)
return
}
const common = {
taskSource: task.provider,
...(result.initialRepoId ? { preselectedRepoId: result.initialRepoId } : {})
}
state.openTaskPage(
task.provider === 'linear'
? { ...common, openLinearIssue: task.issue, openLinearSourceContext: result.sourceContext }
: task.provider === 'jira'
? { ...common, openJiraIssue: task.issue, openJiraSourceContext: result.sourceContext }
: task.provider === 'github'
? {
...common,
openGitHubWorkItem: task.issue,
openGitHubSourceContext: result.sourceContext
}
: {
...common,
openGitLabWorkItem: task.issue,
openGitLabSourceContext: result.sourceContext
},
{ recordTasksInteraction: false }
)
}
@@ -0,0 +1,232 @@
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import { createCmdJTaskSearchRequests } from './task-search-requests'
import { jiraTaskIssue } from './task-search.test-fixture'
import { makeRepo } from '../worktree-jump-palette-test-fixtures'
import { linearSearchIssuesForTarget, linearStatusForTarget } from '@/runtime/runtime-linear-client'
import { jiraSearchIssuesForTarget, jiraStatusForTarget } from '@/runtime/runtime-jira-client'
import { createRuntimeGitLabApi } from '@/runtime/runtime-gitlab-api'
import { gitLabApiFor } from '@/runtime/gitlab-owner-api'
import type { ExecutionHostId } from '../../../../shared/execution-host'
vi.mock('@/runtime/runtime-linear-client', () => ({
linearStatusForTarget: vi.fn(),
linearSearchIssuesForTarget: vi.fn()
}))
vi.mock('@/runtime/runtime-jira-client', () => ({
jiraSearchIssuesForTarget: vi.fn(),
jiraStatusForTarget: vi.fn()
}))
vi.mock('@/runtime/gitlab-owner-api', () => ({ gitLabApiFor: vi.fn() }))
const initial = useAppStore.getInitialState()
afterEach(() => {
useAppStore.setState(initial, true)
vi.resetAllMocks()
})
function requests(
providers: ('jira' | 'linear' | 'github' | 'gitlab')[],
hostIds: string[] = [],
repos = [makeRepo()],
repoIds: string[] = [],
accountBackedTaskSourceHostId: ExecutionHostId = 'local'
) {
return createCmdJTaskSearchRequests({
query: 'login redirect',
settings: createGlobalSettingsFixture({ visibleTaskProviders: providers }),
accountBackedTaskSourceHostId,
repos,
filter: { hostIds, repoIds },
hostLabels: new Map(),
signal: new AbortController().signal
})
}
it('binds Jira results to the returned account rather than the active site', async () => {
vi.mocked(jiraSearchIssuesForTarget).mockResolvedValue([jiraTaskIssue])
vi.mocked(jiraStatusForTarget).mockResolvedValue({
connected: true,
viewer: null,
selectedSiteId: 'all',
sites: [
{
id: 'other-site',
siteUrl: 'https://other.atlassian.net',
email: 'other@example.com',
displayName: 'Other account',
accountId: 'other-account'
}
]
})
const result = await requests(['jira'])[0]?.run()
expect(result?.[0]?.sourceContext).toMatchObject({
hostId: 'local',
accountLabel: 'other@example.com',
providerIdentity: { siteId: 'other-site', projectKey: 'DEMO' }
})
expect(jiraSearchIssuesForTarget).toHaveBeenCalledWith(
{ kind: 'local' },
'text ~ "login redirect*"',
12,
'all',
expect.any(AbortSignal)
)
})
it('scopes Linear queries to the explicit runtime and selected workspace', async () => {
vi.mocked(linearStatusForTarget).mockResolvedValue({
connected: true,
viewer: null,
selectedWorkspaceId: 'workspace-a'
})
vi.mocked(linearSearchIssuesForTarget).mockResolvedValue([])
await requests(['linear'], ['runtime:source'], [])[0]?.run()
expect(linearSearchIssuesForTarget).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'source' },
'login redirect',
12,
'workspace-a'
)
expect(requests(['linear', 'jira'], ['ssh:remote'], [])).toMatchObject([
{ id: 'jira:local', provider: 'jira' }
])
})
it.each(['linear', 'jira'] as const)(
'keeps %s on the captured palette host when the default host changes during status loading',
async (provider) => {
const switchDefaultHost = (): void =>
useAppStore.setState({
settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'other-host' })
})
if (provider === 'linear') {
vi.mocked(linearStatusForTarget).mockImplementation(async () => {
switchDefaultHost()
return { connected: true, viewer: null, selectedWorkspaceId: 'workspace-a' }
})
vi.mocked(linearSearchIssuesForTarget).mockResolvedValue([])
} else {
vi.mocked(jiraStatusForTarget).mockImplementation(async () => {
switchDefaultHost()
return { connected: true, viewer: null, selectedSiteId: 'other-site' }
})
vi.mocked(jiraSearchIssuesForTarget).mockResolvedValue([jiraTaskIssue])
}
const [request] = requests([provider], [], [], [], 'runtime:palette-owner')
expect(request?.id).toBe(`${provider}:runtime:palette-owner`)
const results = await request?.run()
const target = { kind: 'environment', environmentId: 'palette-owner' }
if (provider === 'linear') {
expect(linearStatusForTarget).toHaveBeenCalledWith(target)
expect(linearSearchIssuesForTarget).toHaveBeenCalledWith(
target,
'login redirect',
12,
'workspace-a'
)
} else {
expect(jiraStatusForTarget).toHaveBeenCalledWith(target)
expect(jiraSearchIssuesForTarget).toHaveBeenCalledWith(
target,
'text ~ "login redirect*"',
12,
'other-site',
expect.any(AbortSignal)
)
expect(results?.[0]?.sourceContext.hostId).toBe('runtime:palette-owner')
}
}
)
it('searches desktop-owned Jira for an SSH repository filter and retains the repository seed', async () => {
vi.mocked(jiraStatusForTarget).mockResolvedValue({
connected: true,
viewer: null,
selectedSiteId: 'other-site'
})
vi.mocked(jiraSearchIssuesForTarget).mockResolvedValue([jiraTaskIssue])
const remoteRepo = { ...makeRepo(), id: 'remote-repo', executionHostId: 'ssh:remote' as const }
for (const hostIds of [['ssh:remote'], []]) {
const scoped = requests(['jira'], hostIds, [makeRepo(), remoteRepo], [remoteRepo.id])
expect(scoped).toHaveLength(1)
const results = await scoped[0]?.run()
expect(results?.[0]).toMatchObject({
initialRepoId: remoteRepo.id,
sourceContext: { hostId: 'local', repoId: remoteRepo.id }
})
}
expect(jiraStatusForTarget).toHaveBeenCalledWith({ kind: 'local' })
expect(jiraSearchIssuesForTarget).toHaveBeenCalledWith(
{ kind: 'local' },
'text ~ "login redirect*"',
12,
'other-site',
expect.any(AbortSignal)
)
})
it('queries Jira once per credential host while preserving runtime ownership', async () => {
vi.mocked(jiraStatusForTarget).mockResolvedValue({ connected: true, viewer: null })
vi.mocked(jiraSearchIssuesForTarget).mockResolvedValue([])
const scoped = requests(['jira'], ['ssh:first', 'local', 'ssh:second', 'runtime:source'], [])
expect(scoped.map((request) => request.id)).toEqual(['jira:local', 'jira:runtime:source'])
await Promise.all(scoped.map((request) => request.run()))
expect(jiraStatusForTarget).toHaveBeenCalledTimes(2)
expect(jiraStatusForTarget).toHaveBeenCalledWith({ kind: 'environment', environmentId: 'source' })
expect(jiraSearchIssuesForTarget).toHaveBeenCalledTimes(2)
})
it('routes GitHub search through the repo owner with an open-issue query', async () => {
const fetch = vi.fn().mockResolvedValue([])
useAppStore.setState({ fetchWorkItems: fetch, preflightStatusChecked: false })
const repo = { ...makeRepo(), executionHostId: 'runtime:forge' as const }
await requests(['github'], ['runtime:forge'], [repo])[0]?.run()
expect(fetch).toHaveBeenCalledWith(
repo.id,
repo.path,
12,
'is:issue is:open login redirect',
expect.objectContaining({
sourceContext: expect.objectContaining({ hostId: 'runtime:forge', repoId: repo.id })
})
)
})
it('uses GitLab combined search and never includes merge requests in task results', async () => {
const listWorkItems = vi.fn().mockResolvedValue({
items: [{ type: 'mr', number: 1, title: 'login redirect', state: 'opened' }]
})
vi.mocked(gitLabApiFor).mockReturnValue({
...createRuntimeGitLabApi({
call: async () => {
throw new Error('Unexpected GitLab call')
},
supportsCapability: async () => false
}),
listWorkItems
})
useAppStore.setState({ preflightStatusChecked: false })
const repo = { ...makeRepo(), connectionId: 'remote' }
const result = await requests(['gitlab'], ['ssh:remote'], [repo])[0]?.run()
expect(gitLabApiFor).toHaveBeenCalledWith(
expect.objectContaining({
repoOwnerExecutionHostId: 'ssh:remote',
sourceContext: expect.objectContaining({ hostId: 'ssh:remote' })
})
)
expect(listWorkItems).toHaveBeenCalledWith(
expect.objectContaining({ state: 'opened', query: 'login redirect', perPage: 12 })
)
expect(result).toEqual([])
})
it('does not search disconnected account integrations or folder repositories', async () => {
vi.mocked(linearStatusForTarget).mockResolvedValue({ connected: false, viewer: null })
vi.mocked(jiraStatusForTarget).mockResolvedValue({ connected: false, viewer: null })
for (const request of requests(['linear', 'jira'])) {
expect(await request.run()).toEqual([])
}
expect(linearSearchIssuesForTarget).not.toHaveBeenCalled()
expect(jiraSearchIssuesForTarget).not.toHaveBeenCalled()
expect(requests(['github', 'gitlab'], [], [{ ...makeRepo(), kind: 'folder' }])).toEqual([])
})
@@ -0,0 +1,273 @@
import { useAppStore } from '@/store'
import { linearSearchIssuesForTarget, linearStatusForTarget } from '@/runtime/runtime-linear-client'
import { jiraSearchIssuesForTarget, jiraStatusForTarget } from '@/runtime/runtime-jira-client'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
import { gitLabApiFor } from '@/runtime/gitlab-owner-api'
import { resolveJiraSourceHostId } from '@/lib/jira-source-host'
import {
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import {
normalizeTaskSourceContext,
type TaskSourceContext
} from '../../../../shared/task-source-context'
import { normalizeVisibleTaskProviders, type TaskProvider } from '../../../../shared/task-providers'
import type { Repo } from '../../../../shared/repo-types'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { PaletteFilterState } from './palette-filter'
import { getTaskEligibleRepos } from '../task-page-default-repo-selection'
import { getRepoBackedProviderAvailability } from '../task-source-provider-availability'
import { getTaskPageRepoSourceContext } from '../task-page-source-context'
import { searchJiraIssuesByKeyOrText } from '../jira-key-or-text-search'
import { bindTaskPageJiraItemSourceContext } from '../task-page-jira-item-source-context'
import { makeCmdJTaskSearchResult, type CmdJTaskSearchResult } from './task-search-results'
const RESULT_LIMIT = 12
export type CmdJTaskSearchRequest = {
id: string
provider: TaskProvider
hostLabel: string
run: () => Promise<CmdJTaskSearchResult[]>
}
export function createCmdJTaskSearchRequests(args: {
query: string
repos: readonly Repo[]
settings: GlobalSettings | null
accountBackedTaskSourceHostId: ExecutionHostId
filter: PaletteFilterState
hostLabels: ReadonlyMap<string, string>
signal: AbortSignal
}): CmdJTaskSearchRequest[] {
const { query, settings, filter, signal, hostLabels } = args
const state = useAppStore.getState()
const providers = normalizeVisibleTaskProviders(settings?.visibleTaskProviders)
const requests: CmdJTaskSearchRequest[] = []
const eligibleRepos = args.repos.filter(
(repo) =>
(!filter.repoIds.length || filter.repoIds.includes(repo.id)) &&
(!filter.hostIds.length || filter.hostIds.includes(getRepoExecutionHostId(repo)))
)
for (const repo of getTaskEligibleRepos(eligibleRepos)) {
for (const provider of ['github', 'gitlab'] as const) {
if (!providers.includes(provider)) {
continue
}
const source = getTaskPageRepoSourceContext(repo, provider)
if (!source) {
continue
}
if (
getRepoBackedProviderAvailability({
provider,
contexts: [source],
preflightStatus: state.preflightStatus,
preflightReady: source.hostId === 'local' && state.preflightStatusChecked
}).length
) {
continue
}
const hostLabel = hostLabels.get(source.hostId) ?? source.hostId
requests.push({
id: `${provider}:${source.hostId}:${repo.id}`,
provider,
hostLabel,
run: async () => {
if (signal.aborted) {
return []
}
if (provider === 'github') {
const items = await state.fetchWorkItems(
repo.id,
repo.path,
RESULT_LIMIT,
`is:issue is:open ${query}`,
{ sourceContext: source, requireComplete: true }
)
return items
.filter((issue) => issue.type === 'issue' && issue.state === 'open')
.map((issue) =>
makeCmdJTaskSearchResult({ provider, issue }, source, repo.id, hostLabel)
)
}
const result = await gitLabApiFor({
repoPath: repo.path,
repoId: repo.id,
sourceContext: source,
repoOwnerExecutionHostId: getRepoExecutionHostId(repo)
}).listWorkItems({
repoPath: repo.path,
repoId: repo.id,
sourceContext: source,
state: 'opened',
page: 1,
perPage: RESULT_LIMIT,
query
})
if (result.error?.type === 'not_found') {
return []
}
if (result.error && result.items.length === 0) {
throw new Error(result.error.message)
}
return result.items
.filter((issue) => issue.type === 'issue' && issue.state === 'opened')
.map((issue) => {
const item = { ...issue, repoId: repo.id }
const itemSource =
getTaskPageRepoSourceContext(repo, provider, issue.projectRef) ?? source
return makeCmdJTaskSearchResult(
{ provider, issue: item },
itemSource,
repo.id,
hostLabel
)
})
}
})
}
}
const hostIds = filter.hostIds.length
? filter.hostIds
: filter.repoIds.length
? [...new Set(eligibleRepos.map(getRepoExecutionHostId))]
: [args.accountBackedTaskSourceHostId]
const accountRequestIds = new Set<string>()
for (const requestedHostId of hostIds) {
const parsed = parseExecutionHostId(requestedHostId)
if (!parsed) {
continue
}
const initialRepo =
eligibleRepos.find((repo) => getRepoExecutionHostId(repo) === requestedHostId) ?? null
if (filter.repoIds.length && !initialRepo) {
continue
}
for (const provider of ['linear', 'jira'] as const) {
if (!providers.includes(provider) || (provider === 'linear' && parsed.kind === 'ssh')) {
continue
}
// Jira credentials stay on the desktop for SSH repositories; runtime hosts own theirs.
const hostId =
provider === 'jira'
? resolveJiraSourceHostId({ workspaceHostId: requestedHostId })
: parsed.id
const requestId = `${provider}:${hostId}`
const target = ipcOrRuntimeTargetForHostId(hostId)
if (!target || accountRequestIds.has(requestId)) {
continue
}
const hostLabel = hostLabels.get(hostId) ?? hostId
const source = normalizeTaskSourceContext({
provider,
projectId:
(initialRepo ? getTaskPageRepoSourceContext(initialRepo, 'github')?.projectId : null) ??
initialRepo?.id ??
'account-backed-task-source',
hostId,
repoId: initialRepo?.id ?? null
})
if (!source) {
continue
}
accountRequestIds.add(requestId)
requests.push({
id: requestId,
provider,
hostLabel,
run: async () => {
if (signal.aborted) {
return []
}
return provider === 'linear'
? searchLinear(source, target, query, signal, initialRepo?.id ?? null, hostLabel)
: searchJira(source, target, query, signal, initialRepo?.id ?? null, hostLabel)
}
})
}
}
return requests
}
async function searchLinear(
source: TaskSourceContext,
target: RuntimeClientTarget,
query: string,
signal: AbortSignal,
initialRepoId: string | null,
hostLabel: string
): Promise<CmdJTaskSearchResult[]> {
const status = await linearStatusForTarget(target)
if (signal.aborted || !status.connected) {
return []
}
const workspaceId = status.selectedWorkspaceId ?? status.activeWorkspaceId ?? undefined
const issues = await linearSearchIssuesForTarget(target, query, RESULT_LIMIT, workspaceId)
return issues.flatMap((issue) => {
const itemSource = normalizeTaskSourceContext({
...source,
providerIdentity: {
provider: 'linear',
workspaceId: issue.workspaceId ?? (workspaceId === 'all' ? null : workspaceId) ?? null,
workspaceName: issue.workspaceName ?? null,
teamId: issue.team.id,
teamKey: issue.team.key
},
accountLabel: issue.workspaceName ?? null
})
return itemSource
? [
makeCmdJTaskSearchResult(
{ provider: 'linear', issue },
itemSource,
initialRepoId,
hostLabel
)
]
: []
})
}
async function searchJira(
source: TaskSourceContext,
target: RuntimeClientTarget,
query: string,
signal: AbortSignal,
initialRepoId: string | null,
hostLabel: string
): Promise<CmdJTaskSearchResult[]> {
const status = await jiraStatusForTarget(target)
if (signal.aborted || !status.connected) {
return []
}
const siteId = status.selectedSiteId ?? status.activeSiteId ?? undefined
const issues = await searchJiraIssuesByKeyOrText(query, (jql) =>
jiraSearchIssuesForTarget(target, jql, RESULT_LIMIT, siteId, signal)
)
return issues.flatMap((issue) => {
const itemSource =
bindTaskPageJiraItemSourceContext({
issue,
sites: status.sites ?? [],
sourceContext: source
}) ??
normalizeTaskSourceContext({
...source,
providerIdentity: { provider: 'jira', siteId: issue.siteId ?? null, siteUrl: null },
accountLabel: status.viewer?.email ?? null
})
return itemSource
? [
makeCmdJTaskSearchResult(
{ provider: 'jira', issue },
itemSource,
initialRepoId,
hostLabel
)
]
: []
})
}
@@ -0,0 +1,81 @@
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
import type { JiraIssue } from '../../../../shared/jira-types'
import type { LinearIssue } from '../../../../shared/linear/issue-types'
import {
getTaskSourceCacheScope,
type TaskSourceContext
} from '../../../../shared/task-source-context'
import { parseCmdJTaskSourceUrl } from '@/lib/worktree-palette-task-url-match'
import { isWorktreePaletteQueryTooLarge } from '@/lib/worktree-palette-query-bounds'
export type CmdJTask =
| { provider: 'linear'; issue: LinearIssue }
| { provider: 'jira'; issue: JiraIssue }
| { provider: 'github'; issue: GitHubWorkItem }
| { provider: 'gitlab'; issue: GitLabWorkItem }
export type CmdJTaskSearchResult = {
id: string
task: CmdJTask
title: string
identifier: string
status: string
sourceContext: TaskSourceContext
initialRepoId: string | null
hostLabel: string
}
export function isCmdJTaskSearchQuery(query: string): boolean {
const text = query.trim()
return (
text.length >= 2 &&
!isWorktreePaletteQueryTooLarge(text) &&
!/^\w+:\/\//.test(text) &&
!parseCmdJTaskSourceUrl(text)
)
}
export function makeCmdJTaskSearchResult(
task: CmdJTask,
sourceContext: TaskSourceContext,
initialRepoId: string | null,
hostLabel: string
): CmdJTaskSearchResult {
const identifier =
task.provider === 'linear'
? task.issue.identifier
: task.provider === 'jira'
? task.issue.key
: `#${task.issue.number}`
const status =
task.provider === 'linear'
? task.issue.state.name
: task.provider === 'jira'
? task.issue.status.name
: task.issue.state
return {
id: `task:${getTaskSourceCacheScope(sourceContext)}:${task.issue.url}`,
task,
title: task.issue.title,
identifier,
status,
sourceContext,
initialRepoId,
hostLabel
}
}
export function mergeCmdJTaskSearchResults(
groups: readonly (readonly CmdJTaskSearchResult[])[]
): CmdJTaskSearchResult[] {
const results = new Map<string, CmdJTaskSearchResult>()
for (const group of groups) {
for (const result of group) {
results.set(result.id, result)
}
}
return [...results.values()]
.sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id))
.slice(0, 30)
}
@@ -0,0 +1,41 @@
import type { JiraIssue } from '../../../../shared/jira-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import { makeCmdJTaskSearchResult } from './task-search-results'
export const jiraTaskIssue: JiraIssue = {
id: 'other-site-id',
key: 'DEMO-12',
siteId: 'other-site',
title: 'Fix login redirect',
url: 'https://other.atlassian.net/browse/DEMO-12',
project: { id: 'p', key: 'DEMO', name: 'Demo' },
issueType: { id: 'task', name: 'Task' },
status: {
id: 'open',
name: 'In Progress',
categoryKey: 'indeterminate',
categoryName: 'In Progress'
},
labels: [],
updatedAt: '2026-10-13T00:00:00Z',
createdAt: '2026-10-01T00:00:00Z'
}
export const jiraTaskSource: TaskSourceContext = {
kind: 'task-source',
provider: 'jira',
projectId: 'account-backed-task-source',
hostId: 'local',
providerIdentity: {
provider: 'jira',
siteId: 'other-site',
siteUrl: 'https://other.atlassian.net',
projectKey: 'DEMO'
},
accountLabel: 'other@example.com'
}
export const jiraTaskResult = makeCmdJTaskSearchResult(
{ provider: 'jira', issue: jiraTaskIssue },
jiraTaskSource,
null,
'Local'
)
@@ -11,6 +11,7 @@ import { useWorktreeJumpPaletteListEntries } from './use-worktree-jump-palette-l
import { useWorktreeJumpPaletteSelectionLifecycle } from './use-worktree-jump-palette-selection-lifecycle'
import { useWorktreeJumpPaletteSelectionActions } from './use-worktree-jump-palette-selection-actions'
import { useWorktreeJumpPaletteCreateAction } from './use-worktree-jump-palette-create-action'
import { useWorktreeJumpPaletteTaskSearch } from './use-worktree-jump-palette-task-search'
import { useWorktreeJumpPaletteTaskUrl } from './use-worktree-jump-palette-task-url'
import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput'
import { usePaletteSearchEvaluationContext } from '@/hooks/use-palette-search-evaluation-context'
@@ -63,6 +64,7 @@ export function useWorktreeJumpPaletteController({
createLookupGuard
})
const filter = useWorktreeJumpPaletteFilter({ ...storeState, ...localState })
const taskSearch = useWorktreeJumpPaletteTaskSearch({ ...storeState, ...localState, ...filter })
const worktrees = useWorktreeJumpPaletteWorktrees({
...storeState,
...localState,
@@ -111,6 +113,7 @@ export function useWorktreeJumpPaletteController({
...worktrees,
...openTabs,
...sections,
...taskSearch,
...taskUrl
})
const selectionLifecycle = useWorktreeJumpPaletteSelectionLifecycle({
@@ -124,12 +127,14 @@ export function useWorktreeJumpPaletteController({
...quickActions,
...sections,
...listEntries,
...taskSearch,
...taskUrl
})
const selectionActions = useWorktreeJumpPaletteSelectionActions({
...storeState,
...localState,
...quickActions,
...taskSearch,
...selectionLifecycle
})
const emojiInput = useWorkspaceEmojiShortcodeInput({
@@ -153,6 +158,7 @@ export function useWorktreeJumpPaletteController({
...storeState,
...localState,
...taskUrl,
...taskSearch,
...filter,
...worktrees,
...openTabs,
@@ -15,7 +15,10 @@ import type { WorktreeJumpPaletteSections } from './use-worktree-jump-palette-se
import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-worktrees'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteTaskSearch } from './use-worktree-jump-palette-task-search'
type WorktreeJumpPaletteListEntriesInput = WorktreeJumpPaletteSections &
Pick<WorktreeJumpPaletteTaskSearch, 'taskItems'> &
Pick<WorktreeJumpPaletteWorktrees, 'hasQuery'> &
Pick<WorktreeJumpPaletteLocalState, 'autoSelectedItemIdRef' | 'taskSourceUrl'> &
Pick<WorktreeJumpPaletteSections, 'middleLeadsSections' | 'handleExpandSection'>
@@ -28,7 +31,8 @@ export function useWorktreeJumpPaletteListEntries({
autoSelectedItemIdRef,
taskSourceUrl,
handleExpandSection,
middleLeadsSections
middleLeadsSections,
taskItems
}: WorktreeJumpPaletteListEntriesInput) {
const listEntries = useMemo<PaletteListEntry[]>(() => {
const entries: PaletteListEntry[] = []
@@ -44,6 +48,16 @@ export function useWorktreeJumpPaletteListEntries({
multiPrimaryFirstScreen,
multiPrimaryLayout
} = paletteSections
const pushTasks = (): void => {
if (taskItems.length) {
entries.push({
id: '__header_tasks__',
type: 'section-header',
label: translate('worktreeJumpPalette.tasksHeader', 'Tasks')
})
appendPaletteListEntries(entries, taskItems)
}
}
const pushOverflowHint = (id: string, overflowCount: number, onSeeMore?: () => void): void => {
if (overflowCount > 0) {
entries.push({
@@ -205,6 +219,7 @@ export function useWorktreeJumpPaletteListEntries({
handleExpandSection(trailingSectionKey)
)
pushProjectAndMiddleSections()
pushTasks()
if (showCreateAction) {
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
@@ -223,6 +238,7 @@ export function useWorktreeJumpPaletteListEntries({
if (!openTabsLeadSections) {
pushOpenTabSection()
}
pushTasks()
if (showCreateAction) {
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
@@ -234,7 +250,8 @@ export function useWorktreeJumpPaletteListEntries({
openTabsLeadSections,
paletteSections,
showCreateAction,
taskSourceUrl
taskSourceUrl,
taskItems
])
const selectableItems = useMemo<PaletteItem[]>(
() =>
@@ -1,3 +1,5 @@
import { useWorktreeJumpPaletteTaskSelection } from './use-worktree-jump-palette-task-selection'
import type { WorktreeJumpPaletteTaskSearch } from './use-worktree-jump-palette-task-search'
import { useCallback } from 'react'
import { toast } from 'sonner'
import { activateBrowserPagePaletteResult } from '@/lib/browser-page-palette-activation'
@@ -34,11 +36,15 @@ function getSettingsTargetFromSectionId(sectionId: string): {
}
type WorktreeJumpPaletteSelectionActionsInput = WorktreeJumpPaletteStoreState &
Pick<WorktreeJumpPaletteTaskSearch, 'taskItems'> &
WorktreeJumpPaletteLocalState &
Pick<WorktreeJumpPaletteQuickActions, 'buildQuickActionContext'> &
Pick<WorktreeJumpPaletteSelectionLifecycle, 'focusFallbackSurface' | 'requestBrowserFocus'>
export function useWorktreeJumpPaletteSelectionActions({
visible,
taskItems,
workspaces,
closeModal,
recordFeatureInteraction,
skipRestoreFocusRef,
@@ -245,6 +251,15 @@ export function useWorktreeJumpPaletteSelectionActions({
revealSidebarRow
]
)
const handleSelectTask = useWorktreeJumpPaletteTaskSelection({
visible,
taskItems,
workspaces,
handleSelectWorktree,
recordFeatureInteraction,
skipRestoreFocusRef,
setSelectedItemId
})
const handleSelectItem = useCallback(
(item: PaletteItem) => {
if (item.type === 'worktree') {
@@ -257,6 +272,8 @@ export function useWorktreeJumpPaletteSelectionActions({
handleSelectSimulatorTab(item.result)
} else if (item.type === 'workspace-tab') {
handleSelectWorkspaceTab(item.result)
} else if (item.type === 'task') {
handleSelectTask(item.result)
} else if (item.type === 'settings') {
handleSelectSettings(item.result)
} else {
@@ -264,6 +281,7 @@ export function useWorktreeJumpPaletteSelectionActions({
}
},
[
handleSelectTask,
handleSelectBrowserPage,
handleSelectProjectTarget,
handleSelectQuickAction,
@@ -273,7 +291,7 @@ export function useWorktreeJumpPaletteSelectionActions({
handleSelectWorktree
]
)
return { handleSelectItem }
return { handleSelectItem, handleSelectTask }
}
export type WorktreeJumpPaletteSelectionActions = ReturnType<
@@ -11,6 +11,7 @@ import {
} from '@/lib/worktree-palette-create-action'
import { useAppStore } from '@/store'
import { CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteTaskSearch } from './use-worktree-jump-palette-task-search'
import type { WorktreeJumpPaletteListEntries } from './use-worktree-jump-palette-list-entries'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteOpenTabs } from './use-worktree-jump-palette-open-tabs'
@@ -22,6 +23,7 @@ import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-w
type WorktreeJumpPaletteSelectionLifecycleInput = WorktreeJumpPaletteStoreState &
WorktreeJumpPaletteLocalState &
Pick<WorktreeJumpPaletteTaskSearch, 'taskItems'> &
WorktreeJumpPaletteOpenTabs &
WorktreeJumpPaletteProjectTargets &
WorktreeJumpPaletteQuickActions &
@@ -37,6 +39,7 @@ export function useWorktreeJumpPaletteSelectionLifecycle({
simulatorTabEntries,
workspaceTabEntries,
middleItems,
taskItems,
visible,
wasVisibleRef,
recordFeatureInteraction,
@@ -128,13 +131,18 @@ export function useWorktreeJumpPaletteSelectionLifecycle({
recordFeatureInteraction,
visible
])
const commandSelectedItemId = getNextWorktreePaletteSelection({
currentSelectedItemId: selectedItemId,
queryChanged: false,
selectableItemIds: selectionItemIds,
showCreateAction,
autoSelectCreateAction: taskSourceUrl !== null
})
const commandSelectedItemId =
selectedItemId === CREATE_WORKTREE_ITEM_ID &&
!selectionMovedByUserRef.current &&
taskItems.length
? (taskItems[0]?.id ?? selectedItemId)
: getNextWorktreePaletteSelection({
currentSelectedItemId: selectedItemId,
queryChanged: false,
selectableItemIds: selectionItemIds,
showCreateAction,
autoSelectCreateAction: taskSourceUrl !== null
})
const handleCommandSelectionChange = useCallback(
(nextItemId: string) => {
setSelectedItemId(nextItemId)
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useWorktreeJumpPaletteTaskSearch } from './use-worktree-jump-palette-task-search'
import { createCmdJTaskSearchRequests } from './cmd-j/task-search-requests'
import { jiraTaskResult } from './cmd-j/task-search.test-fixture'
import { EMPTY_PALETTE_FILTER } from './cmd-j/palette-filter'
import { buildPaletteFilterModel } from './cmd-j/palette-filter-options'
import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
vi.mock('./cmd-j/task-search-requests', () => ({ createCmdJTaskSearchRequests: vi.fn() }))
const input: Parameters<typeof useWorktreeJumpPaletteTaskSearch>[0] = {
visible: true,
query: 'login redirect',
settings: createGlobalSettingsFixture({ visibleTaskProviders: ['jira'] }),
repos: [],
hostOptions: [],
defaultHostId: 'local',
filter: EMPTY_PALETTE_FILTER,
filterModel: buildPaletteFilterModel({
repos: [],
worktrees: [],
hostOptions: [],
projects: [],
projectHostSetups: [],
defaultHostId: 'local'
})
}
beforeEach(() => {
vi.useFakeTimers()
vi.mocked(createCmdJTaskSearchRequests).mockReset()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('debounces queries without starting the old request and delays loading feedback', async () => {
const run = vi.fn(() => new Promise<(typeof jiraTaskResult)[]>(() => {}))
vi.mocked(createCmdJTaskSearchRequests).mockReturnValue([
{ id: 'jira', provider: 'jira', hostLabel: 'Local', run }
])
const { result, rerender } = renderHook(useWorktreeJumpPaletteTaskSearch, { initialProps: input })
await act(async () => vi.advanceTimersByTimeAsync(200))
rerender({ ...input, query: 'new query' })
await act(async () => vi.advanceTimersByTimeAsync(299))
expect(run).not.toHaveBeenCalled()
await act(async () => vi.advanceTimersByTimeAsync(1))
expect(run).toHaveBeenCalledTimes(1)
expect(result.current.taskSearchLoading).toBe(false)
await act(async () => vi.advanceTimersByTimeAsync(200))
expect(result.current.taskSearchLoading).toBe(true)
})
it('publishes a fast source while a second is pending, then discards both after close', async () => {
let complete: (results: (typeof jiraTaskResult)[]) => void = () => {}
const slow = new Promise<(typeof jiraTaskResult)[]>((resolve) => {
complete = resolve
})
vi.mocked(createCmdJTaskSearchRequests).mockReturnValue([
{ id: 'fast', provider: 'jira', hostLabel: 'Local', run: async () => [jiraTaskResult] },
{ id: 'slow', provider: 'jira', hostLabel: 'Remote', run: () => slow }
])
const { result, rerender } = renderHook(useWorktreeJumpPaletteTaskSearch, { initialProps: input })
await act(async () => vi.advanceTimersByTimeAsync(300))
expect(result.current.taskItems.map((item) => item.id)).toEqual([jiraTaskResult.id])
rerender({ ...input, visible: false })
await act(async () => complete([{ ...jiraTaskResult, id: 'late' }]))
expect(result.current.taskItems).toEqual([])
rerender(input)
expect(result.current.taskItems).toEqual([])
})
it('ignores unrelated settings and repo object identity changes', async () => {
const run = vi.fn(async () => [jiraTaskResult])
vi.mocked(createCmdJTaskSearchRequests).mockReturnValue([
{ id: 'jira', provider: 'jira', hostLabel: 'Local', run }
])
const { rerender } = renderHook(useWorktreeJumpPaletteTaskSearch, { initialProps: input })
await act(async () => vi.advanceTimersByTimeAsync(300))
rerender({
...input,
settings: createGlobalSettingsFixture({ visibleTaskProviders: ['jira'] }),
repos: []
})
await act(async () => vi.advanceTimersByTimeAsync(500))
expect(run).toHaveBeenCalledTimes(1)
})
it('cancels old searches and ignores their replies after the captured palette host changes', async () => {
let finishOldRequest: (results: (typeof jiraTaskResult)[]) => void = () => {}
const oldRequest = new Promise<(typeof jiraTaskResult)[]>((resolve) => {
finishOldRequest = resolve
})
const run = vi
.fn<() => Promise<(typeof jiraTaskResult)[]>>()
.mockReturnValueOnce(oldRequest)
.mockResolvedValue([{ ...jiraTaskResult, id: 'new-owner-result' }])
vi.mocked(createCmdJTaskSearchRequests).mockReturnValue([
{ id: 'jira', provider: 'jira', hostLabel: 'Local', run }
])
const { result, rerender } = renderHook(useWorktreeJumpPaletteTaskSearch, { initialProps: input })
await act(async () => vi.advanceTimersByTimeAsync(300))
const previousArgs = vi.mocked(createCmdJTaskSearchRequests).mock.calls[0]?.[0]
expect(previousArgs?.accountBackedTaskSourceHostId).toBe('local')
rerender({ ...input, defaultHostId: 'runtime:new-owner' })
expect(previousArgs?.signal.aborted).toBe(true)
await act(async () => vi.advanceTimersByTimeAsync(300))
expect(createCmdJTaskSearchRequests).toHaveBeenLastCalledWith(
expect.objectContaining({ accountBackedTaskSourceHostId: 'runtime:new-owner' })
)
expect(run).toHaveBeenCalledTimes(2)
await act(async () => finishOldRequest([{ ...jiraTaskResult, id: 'old-owner-result' }]))
expect(result.current.taskItems.map((item) => item.id)).toEqual(['new-owner-result'])
})
@@ -0,0 +1,171 @@
import { useEffect, useEffectEvent, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import { forEachWithConcurrency } from '../../../shared/map-with-concurrency'
import { createCmdJTaskSearchRequests } from './cmd-j/task-search-requests'
import {
isCmdJTaskSearchQuery,
mergeCmdJTaskSearchResults,
type CmdJTaskSearchResult
} from './cmd-j/task-search-results'
import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-store-state'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteFilter } from './use-worktree-jump-palette-filter'
import { getRepoExecutionHostId } from '../../../shared/execution-host'
import { TASK_SEARCH_DEBOUNCE_MS } from './task-page-source-context'
type Input = Pick<WorktreeJumpPaletteStoreState, 'visible' | 'repos' | 'settings'> &
Pick<WorktreeJumpPaletteLocalState, 'query' | 'filter'> &
Pick<WorktreeJumpPaletteFilter, 'hostOptions' | 'filterModel' | 'defaultHostId'>
type SearchSnapshot = {
key: string
session: object
results: CmdJTaskSearchResult[]
loading: boolean
failures: string[]
}
const EMPTY_RESULTS: CmdJTaskSearchResult[] = []
const EMPTY_FAILURES: string[] = []
export function useWorktreeJumpPaletteTaskSearch({
visible,
query,
repos,
settings,
filter,
hostOptions,
filterModel,
defaultHostId
}: Input) {
const connectionScope = useAppStore(
useShallow((state) => ({
jira: state.jiraConnectionRevisions,
linear: state.linearStatus,
linearContext: state.linearStatusContextKey
}))
)
const [snapshot, setSnapshot] = useState<SearchSnapshot | null>(null)
const [retryNonce, setRetryNonce] = useState(0)
const hostLabels = useMemo(
() => new Map(hostOptions.map((host) => [host.id, host.label])),
[hostOptions]
)
const session = useMemo(() => ({ visible }), [visible])
const effectiveFilter = {
...filter,
hostIds: [
...new Set(
filter.hostIds.flatMap((id) => [id, ...(filterModel.equivalentHostIdsById?.get(id) ?? [])])
)
]
}
const searchEnabled = visible && isCmdJTaskSearchQuery(query)
const requestKey = JSON.stringify([
query.trim(),
effectiveFilter,
defaultHostId,
settings?.visibleTaskProviders,
repos.map((repo) => [
repo.id,
repo.path,
getRepoExecutionHostId(repo),
repo.gitRemoteIdentity,
repo.ghAccount,
repo.issueSourcePreference,
repo.kind
]),
connectionScope,
retryNonce
])
const buildRequests = useEffectEvent((signal: AbortSignal) =>
createCmdJTaskSearchRequests({
query: query.trim(),
repos,
settings,
accountBackedTaskSourceHostId: defaultHostId,
filter: effectiveFilter,
hostLabels,
signal
})
)
useEffect(() => {
if (!searchEnabled) {
return
}
const controller = new AbortController()
const resultsByRequest = new Map<string, CmdJTaskSearchResult[]>()
const failures: string[] = []
const requests = buildRequests(controller.signal)
let loadingVisible = false
let pending = requests.length
const publish = (): void => {
if (!controller.signal.aborted) {
setSnapshot({
key: requestKey,
session,
results: mergeCmdJTaskSearchResults([...resultsByRequest.values()]),
loading: pending > 0 && loadingVisible,
failures: [...failures]
})
}
}
const loadingTimer = setTimeout(() => {
loadingVisible = true
publish()
}, TASK_SEARCH_DEBOUNCE_MS + 200)
const debounce = setTimeout(() => {
// Account sources go first so many repositories cannot hold their results behind a long queue.
requests.sort(
(a, b) =>
Number(a.provider === 'github' || a.provider === 'gitlab') -
Number(b.provider === 'github' || b.provider === 'gitlab')
)
void forEachWithConcurrency(requests, 4, async (request) => {
if (controller.signal.aborted) {
return
}
try {
const results = await request.run()
resultsByRequest.set(request.id, results)
} catch {
if (!controller.signal.aborted) {
failures.push(`${request.provider} · ${request.hostLabel}`)
}
} finally {
pending -= 1
publish()
}
}).then(() => {
clearTimeout(loadingTimer)
publish()
})
if (requests.length === 0) {
publish()
}
}, TASK_SEARCH_DEBOUNCE_MS)
return () => {
controller.abort()
clearTimeout(debounce)
clearTimeout(loadingTimer)
}
}, [searchEnabled, session, requestKey])
const current =
searchEnabled && snapshot?.key === requestKey && snapshot.session === session ? snapshot : null
const taskItems = useMemo(
() =>
(current?.results ?? EMPTY_RESULTS).map((result) => ({
id: result.id,
type: 'task' as const,
result
})),
[current?.results]
)
const retryTaskSearch = (): void => setRetryNonce((value) => value + 1)
return {
taskItems,
taskSearchLoading: current?.loading ?? false,
taskSearchFailures: current?.failures ?? EMPTY_FAILURES,
retryTaskSearch
}
}
export type WorktreeJumpPaletteTaskSearch = ReturnType<typeof useWorktreeJumpPaletteTaskSearch>
@@ -0,0 +1,52 @@
import { useCallback } from 'react'
import { findCmdJTaskWorkspace, openCmdJTask } from './cmd-j/task-search-activation'
import type { CmdJTaskSearchResult } from './cmd-j/task-search-results'
import type { Worktree } from '../../../shared/worktree/types'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-store-state'
import type { WorktreeJumpPaletteTaskSearch } from './use-worktree-jump-palette-task-search'
type Input = Pick<
WorktreeJumpPaletteStoreState,
'visible' | 'workspaces' | 'recordFeatureInteraction'
> &
Pick<WorktreeJumpPaletteLocalState, 'skipRestoreFocusRef' | 'setSelectedItemId'> &
Pick<WorktreeJumpPaletteTaskSearch, 'taskItems'> & {
handleSelectWorktree: (worktree: Worktree) => void
}
export function useWorktreeJumpPaletteTaskSelection({
visible,
taskItems,
workspaces,
handleSelectWorktree,
recordFeatureInteraction,
skipRestoreFocusRef,
setSelectedItemId
}: Input) {
return useCallback(
(result: CmdJTaskSearchResult, createWorkspace = false) => {
if (!visible || !taskItems.some((item) => item.id === result.id)) {
return
}
const existing = !createWorkspace && findCmdJTaskWorkspace(result, workspaces)
if (existing) {
handleSelectWorktree(existing)
return
}
skipRestoreFocusRef.current = true
setSelectedItemId('')
openCmdJTask(result, createWorkspace)
recordFeatureInteraction('cmd-j')
},
[
visible,
taskItems,
workspaces,
handleSelectWorktree,
recordFeatureInteraction,
skipRestoreFocusRef,
setSelectedItemId
]
)
}
@@ -8,7 +8,8 @@ export function getWorktreeJumpPaletteResultCount(
? controller.worktreeItems.length +
controller.projectTargetItems.length +
controller.middleItems.length +
controller.openTabItems.length
controller.openTabItems.length +
controller.taskItems.length
: controller.selectableItems.length
}
@@ -1,3 +1,4 @@
import { WorktreeJumpPaletteTaskRow } from './worktree-jump-palette-task-row'
import type React from 'react'
import { flushSync } from 'react-dom'
import { CommandItem } from '@/components/ui/command'
@@ -82,6 +83,11 @@ export function WorktreeJumpPaletteEntry({
<WorktreeJumpPaletteWorktreeRow entry={entry} renderKey={renderKey} controller={controller} />
)
}
if (entry.type === 'task') {
return (
<WorktreeJumpPaletteTaskRow entry={entry} renderKey={renderKey} controller={controller} />
)
}
if (entry.type === 'project-target') {
return (
<WorktreeJumpPaletteProjectRow entry={entry} renderKey={renderKey} controller={controller} />
@@ -16,6 +16,10 @@ import type { Worktree } from '../../../shared/worktree/types'
import { CREATE_WORKSPACE_QUICK_ACTION_ID } from '@/components/cmd-j/quick-actions'
import type { CREATE_WORKTREE_ITEM_ID } from '@/lib/worktree-palette-create-action'
import type { CmdJTaskSearchResult } from './cmd-j/task-search-results'
export type TaskPaletteItem = { id: string; type: 'task'; result: CmdJTaskSearchResult }
export type WorktreePaletteItem = {
id: string
type: 'worktree'
@@ -72,6 +76,7 @@ export type CreateWorktreePaletteItem = {
}
export type PaletteItem =
| TaskPaletteItem
| WorktreePaletteItem
| ProjectTargetPaletteItem
| SettingsPaletteItem
@@ -1,3 +1,5 @@
import { ShortcutKeyCombo } from './ShortcutKeyCombo'
import { WorktreeJumpPaletteTaskSearchStatus } from './worktree-jump-palette-task-search-status'
import type React from 'react'
import { CommandDialog, CommandEmpty, CommandInput, CommandList } from '@/components/ui/command'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -20,6 +22,10 @@ export function WorktreeJumpPaletteSurface({
}: {
controller: WorktreeJumpPaletteController
}): React.JSX.Element {
const selectedTask = controller.taskItems.find(
(item) => item.id === controller.commandSelectedItemId
)
const modKey = navigator.userAgent.includes('Mac') ? '⌘' : 'Ctrl'
const emptyState = getWorktreeJumpPaletteEmptyState(controller)
const resultCount = getWorktreeJumpPaletteResultCount(controller)
const paletteDialog = (
@@ -32,7 +38,7 @@ export function WorktreeJumpPaletteSurface({
title={translate('auto.components.WorktreeJumpPalette.4ee378034d', 'Jump to...')}
description={translate(
'auto.components.WorktreeJumpPalette.2770f02910',
'Search chats, terminals, workspaces, settings, and actions'
'Search chats, terminals, workspaces, tasks, settings, and actions'
)}
overlayClassName="bg-black/55 backdrop-blur-[2px]"
contentClassName="top-[min(10%,4rem)] w-[900px] max-w-[96vw] max-h-[min(90vh,calc(100vh-1.5rem))] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl"
@@ -42,6 +48,16 @@ export function WorktreeJumpPaletteSurface({
onValueChange: controller.handleCommandSelectionChange,
className: 'jump-palette-command bg-transparent',
onKeyDownCapture: (event: React.KeyboardEvent) => {
if (
selectedTask &&
event.key === 'Enter' &&
(navigator.userAgent.includes('Mac') ? event.metaKey : event.ctrlKey)
) {
event.preventDefault()
event.stopPropagation()
controller.handleSelectTask(selectedTask.result, true)
return
}
if (
controller.selectionMovedByUserRef &&
['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp'].includes(event.key)
@@ -55,7 +71,7 @@ export function WorktreeJumpPaletteSurface({
ref={controller.inputRef}
placeholder={translate(
'auto.components.WorktreeJumpPalette.27f10cca63',
'Search chats, terminals, workspaces, settings, and actions...'
'Search chats, terminals, workspaces, tasks, settings, and actions...'
)}
value={controller.query}
onValueChange={controller.emojiInput.handleValueChange}
@@ -131,9 +147,20 @@ export function WorktreeJumpPaletteSurface({
))}
</>
)}
<WorktreeJumpPaletteTaskSearchStatus
loading={controller.taskSearchLoading}
failures={controller.taskSearchFailures}
onRetry={controller.retryTaskSearch}
/>
</CommandList>
<div className="flex items-center justify-end border-t border-border/60 px-3.5 py-2.5 text-[11px] text-muted-foreground/82">
<div className="flex items-center gap-2">
{selectedTask ? (
<>
<ShortcutKeyCombo keys={[modKey, 'Enter']} />
<span>{translate('worktreeJumpPalette.taskCreate', 'Create workspace')}</span>
</>
) : null}
<FooterKey>
{translate('auto.components.WorktreeJumpPalette.f65d992a11', 'Enter')}
</FooterKey>
@@ -0,0 +1,40 @@
import { CircleDot } from 'lucide-react'
import { CommandItem } from '@/components/ui/command'
import type { TaskPaletteItem } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller'
export function WorktreeJumpPaletteTaskRow({
entry,
renderKey,
controller
}: {
entry: TaskPaletteItem
renderKey: string
controller: WorktreeJumpPaletteController
}) {
const result = entry.result
return (
<CommandItem
value={renderKey}
onSelect={() => controller.handleSelectItem(entry)}
selection="palette"
className="group mx-0.5 cursor-pointer text-left"
>
<div className="flex w-full min-w-0 items-center gap-3 py-1">
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">{result.identifier}</span>
<span className="truncate text-[14px] font-semibold text-foreground">
{result.title}
</span>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{result.task.provider} · {result.sourceContext.accountLabel ?? result.hostLabel} ·{' '}
{result.status}
</div>
</div>
</div>
</CommandItem>
)
}
@@ -0,0 +1,43 @@
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
export function WorktreeJumpPaletteTaskSearchStatus({
loading,
failures,
onRetry
}: {
loading: boolean
failures: readonly string[]
onRetry: () => void
}) {
if (!loading && !failures.length) {
return null
}
return (
<div className="mx-3 my-2 flex items-center gap-2 text-xs text-muted-foreground" role="status">
{loading ? (
<>
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<span>{translate('worktreeJumpPalette.taskSearchLoading', 'Searching tasks…')}</span>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate">
{translate(
'worktreeJumpPalette.taskSearchFailed',
'Some task sources could not be searched'
)}
: {failures.join(', ')}
</span>
<Button variant="ghost" size="sm" onClick={onRetry}>
{translate('worktreeJumpPalette.taskSearchRetry', 'Retry')}
</Button>
</>
)}
</div>
)
}
+2 -17
View File
@@ -1210,20 +1210,6 @@
"a05bcdaf57": "Agents View",
"f63ea281e3": "Threaded left-sidebar feed for agent completions and blocking states.",
"fb82ea1d7a": "Automatically materialize configured files or folders into newly created worktrees.",
"nativeChat": {
"resumeCopy": "When Orca quits or installs an update, chats that were working are automatically resumed when Orca is reopened.",
"resumeTitle": "Resume working chats automatically after a restart",
"resumeToggleLabel": "Toggle automatic resume after a restart",
"shellEnvCopy": "Codex and Claude chats start with every variable your login shell exports, the same as a terminal. Turn off to choose which ones they get.",
"shellEnvNameAdd": "Add",
"shellEnvNamePlaceholder": "Variable name",
"shellEnvNameRemove": "Remove {{value0}}",
"shellEnvNamesAlwaysPassed": "PATH, locale, and SSH_AUTH_SOCK are always passed. Applies the next time a chat starts or resumes.",
"shellEnvNamesEmpty": "No variables added yet.",
"shellEnvNamesLabel": "Variables to pass from your shell",
"shellEnvTitle": "Use your shell environment",
"shellEnvToggleLabel": "Toggle using your shell environment"
},
"newWorktreeCardStyle": {
"copy": "Preview updated worktree-card layout, metadata placement, card-display menu options, and status presentation."
}
@@ -2861,8 +2847,7 @@
},
"composer": {
"effort": "Effort",
"imageUnsupported": "Image paste is not supported for this agent.",
"permissions": "Permissions"
"imageUnsupported": "Image paste is not supported for this agent."
},
"failureWords": {
"accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.",
@@ -2886,9 +2871,9 @@
"attachmentTotalTooLarge": "The images on this message are too large together, so the message was not sent.",
"attachmentUnreadable": "An attachment on this message couldn't be read, so the message was not sent.",
"attachmentUnsupportedType": "{{agent}} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.",
"cancelUnconfirmed": "Cancellation was not confirmed.",
"backgroundTaskNotStopped": "The background task wasn't stopped.",
"backgroundTasksNotStopped": "The background tasks weren't stopped.",
"cancelUnconfirmed": "Cancellation was not confirmed.",
"cancelled": "This message was withdrawn before the agent started it.",
"chatClosed": "The chat closed before this message was sent.",
"chooseClaudeAccount": "Choose or add one in Claude Accounts settings.",
+8 -3
View File
@@ -468,7 +468,12 @@
"taskUrl": {
"loadingHint": "Loading {{value0}}…",
"createHint": "Create worktree from {{value0}}"
}
},
"tasksHeader": "Tasks",
"taskCreate": "Create workspace",
"taskSearchLoading": "Searching tasks…",
"taskSearchFailed": "Some task sources could not be searched",
"taskSearchRetry": "Retry"
},
"auto": {
"App": {
@@ -2669,8 +2674,8 @@
"repoGroupBadge": "Repo group",
"pluginCommandFailed": "Could not run the plugin command.",
"recentChatsTerminalsHeader": "Recent Chats & Terminals",
"27f10cca63": "Search chats, terminals, workspaces, settings, and actions...",
"2770f02910": "Search chats, terminals, workspaces, settings, and actions",
"27f10cca63": "Search chats, terminals, workspaces, tasks, settings, and actions...",
"2770f02910": "Search chats, terminals, workspaces, tasks, settings, and actions",
"lastActiveTime": "Last active {{value0}} ago"
},
"github": {
@@ -10,6 +10,8 @@ import {
jiraLookupIssueSummary,
jiraReadStatus,
jiraSearchIssues,
jiraSearchIssuesForTarget,
jiraStatusForTarget,
jiraSearchUsers
} from './runtime-jira-client'
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
@@ -23,6 +25,7 @@ type RuntimeSubscribeArgs = Parameters<typeof window.api.runtimeEnvironments.sub
type RuntimeSubscribeCallbacks = Parameters<typeof window.api.runtimeEnvironments.subscribe>[1]
const jiraSearchIssuesLocal = vi.fn()
const jiraStatusLocal = vi.fn()
const jiraListAssignableUsersLocal = vi.fn()
const jiraSearchUsersLocal = vi.fn()
const jiraProjectUsersLocal = vi.fn()
@@ -36,6 +39,7 @@ const runtimeSubscribe = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
jiraSearchIssuesLocal.mockReset()
jiraStatusLocal.mockReset()
jiraListAssignableUsersLocal.mockReset()
jiraSearchUsersLocal.mockReset()
jiraProjectUsersLocal.mockReset()
@@ -48,6 +52,7 @@ beforeEach(() => {
vi.stubGlobal('window', {
api: {
jira: {
status: jiraStatusLocal,
readStatus: jiraReadStatusLocal,
lookupIssueSummary: jiraLookupIssueSummaryLocal,
cancelIssueSummary: jiraCancelIssueSummaryLocal,
@@ -185,6 +190,39 @@ describe('runtime Jira client search bounds', () => {
expect(runtimeCall).not.toHaveBeenCalled()
})
it('keeps explicit Jira status and search on their owner with the selected site and query bounds', async () => {
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'status.get') {
return createCompatibleRuntimeStatusResponse()
}
return {
id: 'rpc-result',
ok: true,
result:
args.method === 'jira.status'
? { connected: true, viewer: null, selectedSiteId: 'all' }
: [],
_meta: { runtimeId: 'source-owner-runtime' }
}
})
const target = { kind: 'environment', environmentId: 'source-owner' } as const
await expect(jiraStatusForTarget(target)).resolves.toMatchObject({
connected: true,
selectedSiteId: 'all'
})
await jiraSearchIssuesForTarget(target, 'text ~ "login*"', 12, 'all')
await jiraSearchIssuesForTarget(target, 'secret-token-value'.repeat(1024), 12)
expect(runtimeCall).toHaveBeenCalledTimes(3)
expect(runtimeCall).toHaveBeenLastCalledWith({
selector: 'source-owner',
method: 'jira.searchIssues',
params: { jql: 'text ~ "login*"', limit: 12, siteId: 'all' },
timeoutMs: 30_000
})
expect(jiraStatusLocal).not.toHaveBeenCalled()
expect(jiraSearchIssuesLocal).not.toHaveBeenCalled()
})
it('rejects oversized runtime Jira assignee search before RPC', async () => {
await expect(
jiraListAssignableUsers(
@@ -15,6 +15,7 @@ import type {
JiraTransition,
JiraViewer
} 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 { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
@@ -52,7 +53,12 @@ export type { JiraCommentResult } from './runtime-jira-detail-client'
import type { JiraCommentResult } from './runtime-jira-detail-client'
export async function jiraStatus(settings: RuntimeJiraSettings): Promise<JiraConnectionStatus> {
const target = getJiraRuntimeTarget(settings)
return jiraStatusForTarget(getJiraRuntimeTarget(settings))
}
export async function jiraStatusForTarget(
target: RuntimeClientTarget
): Promise<JiraConnectionStatus> {
return parseJiraConnectionStatus(
target.kind === 'environment'
? await callRuntimeRpc<unknown>(target, 'jira.status', undefined, { timeoutMs: 15_000 })
@@ -117,11 +123,20 @@ export async function jiraSearchIssues(
limit?: number,
siteId?: JiraSiteSelection | null,
signal?: AbortSignal
): Promise<JiraIssue[]> {
return jiraSearchIssuesForTarget(getJiraRuntimeTarget(settings), jql, limit, siteId, signal)
}
export async function jiraSearchIssuesForTarget(
target: RuntimeClientTarget,
jql: string,
limit?: number,
siteId?: JiraSiteSelection | null,
signal?: AbortSignal
): Promise<JiraIssue[]> {
if (!isRuntimeProviderSearchQueryWithinLimit(jql)) {
return []
}
const target = getJiraRuntimeTarget(settings)
const args = { jql, limit, siteId: siteId ?? undefined }
if (target.kind === 'environment') {
return callRuntimeRpc<JiraIssue[]>(target, 'jira.searchIssues', args, {
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
linearListIssues,
linearSearchIssues,
linearSearchIssuesForTarget,
linearSelectWorkspace,
linearStatus
linearStatus,
linearStatusForTarget
} from './runtime-linear-client'
import {
linearCreateIssue,
@@ -303,6 +305,30 @@ describe('runtime linear client', () => {
expect(linearListIssuesLocal).not.toHaveBeenCalled()
})
it('routes explicit search targets with the same workspace and query bounds as legacy callers', async () => {
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) =>
Promise.resolve({
id: 'rpc-result',
ok: true,
result: args.method === 'linear.status' ? { connected: true, viewer: null } : [],
_meta: { runtimeId: 'runtime-1' }
})
)
const target = { kind: 'environment', environmentId: 'source-owner' } as const
await expect(linearStatusForTarget(target)).resolves.toEqual({ connected: true, viewer: null })
await linearSearchIssuesForTarget(target, 'bug', 12, 'workspace-a')
await linearSearchIssuesForTarget(target, 'secret-token-value'.repeat(1024), 12)
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2)
expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({
selector: 'source-owner',
method: 'linear.searchIssues',
params: { query: 'bug', limit: 12, workspaceId: 'workspace-a' },
timeoutMs: 30_000
})
expect(linearStatusLocal).not.toHaveBeenCalled()
expect(linearSearchIssuesLocal).not.toHaveBeenCalled()
})
it('wraps legacy remote Linear issue list arrays as collection results', async () => {
runtimeEnvironmentCall.mockResolvedValueOnce({
id: 'rpc-list',
@@ -78,7 +78,12 @@ function normalizeLinearIssueCollectionResult(
export async function linearStatus(
settings: RuntimeLinearSettings
): Promise<LinearConnectionStatus> {
const target = getLinearRuntimeTarget(settings)
return linearStatusForTarget(getLinearRuntimeTarget(settings))
}
export async function linearStatusForTarget(
target: RuntimeClientTarget
): Promise<LinearConnectionStatus> {
return target.kind === 'environment'
? callRuntimeRpc<LinearConnectionStatus>(target, 'linear.status', undefined, {
timeoutMs: 15_000
@@ -161,11 +166,19 @@ export async function linearSearchIssues(
query: string,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearIssue[]> {
return linearSearchIssuesForTarget(getLinearRuntimeTarget(settings), query, limit, workspaceId)
}
export async function linearSearchIssuesForTarget(
target: RuntimeClientTarget,
query: string,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearIssue[]> {
if (!isRuntimeProviderSearchQueryWithinLimit(query)) {
return []
}
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearIssue[]>(
target,