mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user