From 3bf9c85993bd5f4e7f4a462cfe78db1465bdebfd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:16:03 -0700 Subject: [PATCH] Search connected tasks by keyword from Cmd+J (#28617) Search Jira tasks from Cmd+J with captured execution-owner routing and stale-request protection. --- .../WorktreeJumpPalette.task-search.test.tsx | 184 ++++++++++++ .../cmd-j/task-search-activation.test.ts | 99 +++++++ .../cmd-j/task-search-activation.ts | 140 +++++++++ .../cmd-j/task-search-requests.test.ts | 232 +++++++++++++++ .../components/cmd-j/task-search-requests.ts | 273 ++++++++++++++++++ .../components/cmd-j/task-search-results.ts | 81 ++++++ .../cmd-j/task-search.test-fixture.ts | 41 +++ .../use-worktree-jump-palette-controller.ts | 6 + .../use-worktree-jump-palette-list-entries.ts | 21 +- ...worktree-jump-palette-selection-actions.ts | 20 +- ...rktree-jump-palette-selection-lifecycle.ts | 22 +- ...worktree-jump-palette-task-search.test.tsx | 115 ++++++++ .../use-worktree-jump-palette-task-search.ts | 171 +++++++++++ ...se-worktree-jump-palette-task-selection.ts | 52 ++++ .../worktree-jump-palette-empty-state.ts | 3 +- .../worktree-jump-palette-entry.tsx | 6 + .../components/worktree-jump-palette-model.ts | 5 + .../worktree-jump-palette-surface.tsx | 31 +- .../worktree-jump-palette-task-row.tsx | 40 +++ ...rktree-jump-palette-task-search-status.tsx | 43 +++ .../src/i18n/en-runtime-required.json | 19 +- src/renderer/src/i18n/locales/en.json | 11 +- .../src/runtime/runtime-jira-client.test.ts | 38 +++ .../src/runtime/runtime-jira-client.ts | 19 +- .../src/runtime/runtime-linear-client.test.ts | 28 +- .../src/runtime/runtime-linear-client.ts | 17 +- 26 files changed, 1679 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/components/WorktreeJumpPalette.task-search.test.tsx create mode 100644 src/renderer/src/components/cmd-j/task-search-activation.test.ts create mode 100644 src/renderer/src/components/cmd-j/task-search-activation.ts create mode 100644 src/renderer/src/components/cmd-j/task-search-requests.test.ts create mode 100644 src/renderer/src/components/cmd-j/task-search-requests.ts create mode 100644 src/renderer/src/components/cmd-j/task-search-results.ts create mode 100644 src/renderer/src/components/cmd-j/task-search.test-fixture.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-task-search.test.tsx create mode 100644 src/renderer/src/components/use-worktree-jump-palette-task-search.ts create mode 100644 src/renderer/src/components/use-worktree-jump-palette-task-selection.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-task-row.tsx create mode 100644 src/renderer/src/components/worktree-jump-palette-task-search-status.tsx diff --git a/src/renderer/src/components/WorktreeJumpPalette.task-search.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.task-search.test.tsx new file mode 100644 index 00000000000..ff9549c8578 --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.task-search.test.tsx @@ -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()), + 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()), + useTranslation: () => ({ t: (_: string, fallback: string) => fallback }) +})) +vi.mock('@/components/ui/command', () => ({ + CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, + CommandInput: forwardRef< + HTMLInputElement, + { value: string; onValueChange: (value: string) => void } + >(function Input({ value, onValueChange }, ref) { + return ( + onValueChange(e.target.value)} + /> + ) + }), + CommandList: forwardRef(function List( + { children }, + ref + ) { + return
{children}
+ }), + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandItem: ({ + children, + onSelect, + value + }: { + children: ReactNode + onSelect: () => void + value: string + }) => ( + + ) +})) + +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() + 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() + 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() + 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() + 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()) +}) diff --git a/src/renderer/src/components/cmd-j/task-search-activation.test.ts b/src/renderer/src/components/cmd-j/task-search-activation.test.ts new file mode 100644 index 00000000000..3c70fcdcb4e --- /dev/null +++ b/src/renderer/src/components/cmd-j/task-search-activation.test.ts @@ -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' + }) + ) +}) diff --git a/src/renderer/src/components/cmd-j/task-search-activation.ts b/src/renderer/src/components/cmd-j/task-search-activation.ts new file mode 100644 index 00000000000..6a11b89f259 --- /dev/null +++ b/src/renderer/src/components/cmd-j/task-search-activation.ts @@ -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 } + ) +} diff --git a/src/renderer/src/components/cmd-j/task-search-requests.test.ts b/src/renderer/src/components/cmd-j/task-search-requests.test.ts new file mode 100644 index 00000000000..d008dd01edb --- /dev/null +++ b/src/renderer/src/components/cmd-j/task-search-requests.test.ts @@ -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([]) +}) diff --git a/src/renderer/src/components/cmd-j/task-search-requests.ts b/src/renderer/src/components/cmd-j/task-search-requests.ts new file mode 100644 index 00000000000..4718e6c1fac --- /dev/null +++ b/src/renderer/src/components/cmd-j/task-search-requests.ts @@ -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 +} + +export function createCmdJTaskSearchRequests(args: { + query: string + repos: readonly Repo[] + settings: GlobalSettings | null + accountBackedTaskSourceHostId: ExecutionHostId + filter: PaletteFilterState + hostLabels: ReadonlyMap + 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() + 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 { + 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 { + 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 + ) + ] + : [] + }) +} diff --git a/src/renderer/src/components/cmd-j/task-search-results.ts b/src/renderer/src/components/cmd-j/task-search-results.ts new file mode 100644 index 00000000000..addf15e708f --- /dev/null +++ b/src/renderer/src/components/cmd-j/task-search-results.ts @@ -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() + 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) +} diff --git a/src/renderer/src/components/cmd-j/task-search.test-fixture.ts b/src/renderer/src/components/cmd-j/task-search.test-fixture.ts new file mode 100644 index 00000000000..7d00ebce4f6 --- /dev/null +++ b/src/renderer/src/components/cmd-j/task-search.test-fixture.ts @@ -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' +) diff --git a/src/renderer/src/components/use-worktree-jump-palette-controller.ts b/src/renderer/src/components/use-worktree-jump-palette-controller.ts index a6cec2fe1f0..393e38bbaca 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-controller.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-controller.ts @@ -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, diff --git a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts index a09759c9818..cb28752a8e5 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts @@ -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 & Pick & Pick & Pick @@ -28,7 +31,8 @@ export function useWorktreeJumpPaletteListEntries({ autoSelectedItemIdRef, taskSourceUrl, handleExpandSection, - middleLeadsSections + middleLeadsSections, + taskItems }: WorktreeJumpPaletteListEntriesInput) { const listEntries = useMemo(() => { 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( () => diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts index 6e663c1fe6b..e1c656a2064 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts @@ -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 & WorktreeJumpPaletteLocalState & Pick & Pick 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< diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts index 5fd2159f8ca..10492e9d10a 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts @@ -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 & 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) diff --git a/src/renderer/src/components/use-worktree-jump-palette-task-search.test.tsx b/src/renderer/src/components/use-worktree-jump-palette-task-search.test.tsx new file mode 100644 index 00000000000..871388316c1 --- /dev/null +++ b/src/renderer/src/components/use-worktree-jump-palette-task-search.test.tsx @@ -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[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']) +}) diff --git a/src/renderer/src/components/use-worktree-jump-palette-task-search.ts b/src/renderer/src/components/use-worktree-jump-palette-task-search.ts new file mode 100644 index 00000000000..c098fdaefa7 --- /dev/null +++ b/src/renderer/src/components/use-worktree-jump-palette-task-search.ts @@ -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 & + Pick & + Pick +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(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() + 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 diff --git a/src/renderer/src/components/use-worktree-jump-palette-task-selection.ts b/src/renderer/src/components/use-worktree-jump-palette-task-selection.ts new file mode 100644 index 00000000000..3cd27a47789 --- /dev/null +++ b/src/renderer/src/components/use-worktree-jump-palette-task-selection.ts @@ -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 & + Pick & { + 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 + ] + ) +} diff --git a/src/renderer/src/components/worktree-jump-palette-empty-state.ts b/src/renderer/src/components/worktree-jump-palette-empty-state.ts index e57377f518b..f50e3c67517 100644 --- a/src/renderer/src/components/worktree-jump-palette-empty-state.ts +++ b/src/renderer/src/components/worktree-jump-palette-empty-state.ts @@ -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 } diff --git a/src/renderer/src/components/worktree-jump-palette-entry.tsx b/src/renderer/src/components/worktree-jump-palette-entry.tsx index 9ed2208dba8..df6ba1d4e84 100644 --- a/src/renderer/src/components/worktree-jump-palette-entry.tsx +++ b/src/renderer/src/components/worktree-jump-palette-entry.tsx @@ -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({ ) } + if (entry.type === 'task') { + return ( + + ) + } if (entry.type === 'project-target') { return ( diff --git a/src/renderer/src/components/worktree-jump-palette-model.ts b/src/renderer/src/components/worktree-jump-palette-model.ts index 5721c2aa76a..8cbf39d3b19 100644 --- a/src/renderer/src/components/worktree-jump-palette-model.ts +++ b/src/renderer/src/components/worktree-jump-palette-model.ts @@ -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 diff --git a/src/renderer/src/components/worktree-jump-palette-surface.tsx b/src/renderer/src/components/worktree-jump-palette-surface.tsx index 54f3765f66d..919e350630f 100644 --- a/src/renderer/src/components/worktree-jump-palette-surface.tsx +++ b/src/renderer/src/components/worktree-jump-palette-surface.tsx @@ -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({ ))} )} +
+ {selectedTask ? ( + <> + + {translate('worktreeJumpPalette.taskCreate', 'Create workspace')} + + ) : null} {translate('auto.components.WorktreeJumpPalette.f65d992a11', 'Enter')} diff --git a/src/renderer/src/components/worktree-jump-palette-task-row.tsx b/src/renderer/src/components/worktree-jump-palette-task-row.tsx new file mode 100644 index 00000000000..0d0387005f5 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-task-row.tsx @@ -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 ( + controller.handleSelectItem(entry)} + selection="palette" + className="group mx-0.5 cursor-pointer text-left" + > +
+
+
+ ) +} diff --git a/src/renderer/src/components/worktree-jump-palette-task-search-status.tsx b/src/renderer/src/components/worktree-jump-palette-task-search-status.tsx new file mode 100644 index 00000000000..6c35138ec36 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-task-search-status.tsx @@ -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 ( +
+ {loading ? ( + <> +
+ ) +} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d6c0608d308..fbe83a1c458 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 11de559a293..31f100905fc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/runtime/runtime-jira-client.test.ts b/src/renderer/src/runtime/runtime-jira-client.test.ts index 82c456d6ba8..4370a6450b3 100644 --- a/src/renderer/src/runtime/runtime-jira-client.test.ts +++ b/src/renderer/src/runtime/runtime-jira-client.test.ts @@ -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[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( diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 3d6ff952052..5195c55fafe 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -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 { - const target = getJiraRuntimeTarget(settings) + return jiraStatusForTarget(getJiraRuntimeTarget(settings)) +} + +export async function jiraStatusForTarget( + target: RuntimeClientTarget +): Promise { return parseJiraConnectionStatus( target.kind === 'environment' ? await callRuntimeRpc(target, 'jira.status', undefined, { timeoutMs: 15_000 }) @@ -117,11 +123,20 @@ export async function jiraSearchIssues( limit?: number, siteId?: JiraSiteSelection | null, signal?: AbortSignal +): Promise { + return jiraSearchIssuesForTarget(getJiraRuntimeTarget(settings), jql, limit, siteId, signal) +} + +export async function jiraSearchIssuesForTarget( + target: RuntimeClientTarget, + jql: string, + limit?: number, + siteId?: JiraSiteSelection | null, + signal?: AbortSignal ): Promise { if (!isRuntimeProviderSearchQueryWithinLimit(jql)) { return [] } - const target = getJiraRuntimeTarget(settings) const args = { jql, limit, siteId: siteId ?? undefined } if (target.kind === 'environment') { return callRuntimeRpc(target, 'jira.searchIssues', args, { diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts index 6310d4e1e1e..faa0bf73d09 100644 --- a/src/renderer/src/runtime/runtime-linear-client.test.ts +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -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', diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index b4199affe47..4c2281985e2 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -78,7 +78,12 @@ function normalizeLinearIssueCollectionResult( export async function linearStatus( settings: RuntimeLinearSettings ): Promise { - const target = getLinearRuntimeTarget(settings) + return linearStatusForTarget(getLinearRuntimeTarget(settings)) +} + +export async function linearStatusForTarget( + target: RuntimeClientTarget +): Promise { return target.kind === 'environment' ? callRuntimeRpc(target, 'linear.status', undefined, { timeoutMs: 15_000 @@ -161,11 +166,19 @@ export async function linearSearchIssues( query: string, limit?: number, workspaceId?: LinearWorkspaceSelection | null +): Promise { + return linearSearchIssuesForTarget(getLinearRuntimeTarget(settings), query, limit, workspaceId) +} + +export async function linearSearchIssuesForTarget( + target: RuntimeClientTarget, + query: string, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null ): Promise { if (!isRuntimeProviderSearchQueryWithinLimit(query)) { return [] } - const target = getLinearRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target,