refactor: group Jira and Linear task-page modules by domain (#42009)

* refactor: group Jira and Linear task-page modules by domain

* refactor: remove orphaned Linear refresh test entry
This commit is contained in:
Neil
2026-11-13 04:31:03 -08:00
committed by GitHub
parent 1f37db301c
commit e81a89c5ff
129 changed files with 958 additions and 3575 deletions
+10 -10
View File
@@ -156,18 +156,18 @@
1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts
3 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts
11 src/renderer/src/components/task-page/TaskPage.tsx
2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx
1 src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts
4 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts
4 src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts
1 src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts
2 src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx
1 src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts
4 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-metadata.ts
4 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts
1 src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts
1 src/renderer/src/components/task-page/linear/Filters.tsx
4 src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx
8 src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts
4 src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx
8 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts
4 src/renderer/src/components/task-page/linear/use-task-page-linear-context-state.ts
6 src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts
4 src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts
2 src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts
6 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts
4 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts
2 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts
5 src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts
3 src/renderer/src/components/task-page/use-task-page-detail-routing.ts
2 src/renderer/src/components/task-page/use-task-page-provider-metadata.ts
+18 -18
View File
@@ -592,26 +592,26 @@
3 src/renderer/src/components/task-page/github/listing/use-task-page-github-search-pagination.ts
4 src/renderer/src/components/task-page/github/cache/use-task-page-work-items-cache-selection.ts
1 src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.ts
2 src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx
1 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts
33 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts
2 src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts
2 src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts
1 src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts
2 src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts
2 src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts
2 src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts
1 src/renderer/src/components/task-page/linear/IssueTable.tsx
7 src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts
2 src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx
1 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts
33 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts
2 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts
2 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts
1 src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts
2 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts
2 src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts
2 src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts
1 src/renderer/src/components/task-page/linear/issues/IssueTable.tsx
7 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts
3 src/renderer/src/components/task-page/linear/use-task-page-linear-context-actions.ts
1 src/renderer/src/components/task-page/linear/use-task-page-linear-context-state.ts
4 src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts
1 src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts
1 src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts
6 src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts
13 src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts
20 src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts
2 src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts
4 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts
1 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts
1 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts
6 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts
13 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts
20 src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts
2 src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts
1 src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts
10 src/renderer/src/components/task-page/use-task-page-composer-actions.ts
3 src/renderer/src/components/task-page/use-task-page-detail-routing.ts
+1 -1
View File
@@ -13,7 +13,7 @@ src/renderer/src/components/settings/skills/linear/LinearAgentSkillPane.tsx
src/renderer/src/components/settings/tasks/use-task-source-provider-readiness.ts
src/renderer/src/components/sidebar/workspace-card/details/use-worktree-card-review-details.ts
src/renderer/src/components/task-page/jira/Content.tsx
src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts
src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts
src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts
src/renderer/src/hooks/use-integration-provider-status-refresh.ts
@@ -5092,26 +5092,16 @@
"src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.test.tsx": 1769,
"src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx": 1504,
"src/renderer/src/components/task-page/jira/Content.board-owner.test.tsx": 2582,
"src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx": 1122,
"src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx": 2192,
"src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts": 69,
"src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx": 2287,
"src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx": 3326,
"src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts": 2324,
"src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.test.ts": 204,
"src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx": 3457,
"src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx": 2642,
"src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx": 1466,
"src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx": 1501,
"src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx": 571,
"src/renderer/src/components/task-page/linear/IssueTable.test.tsx": 11230,
"src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx": 1978,
"src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx": 2642,
"src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx": 1466,
"src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx": 1501,
"src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx": 571,
"src/renderer/src/components/task-page/linear/task-page-linear-creation-lifetime.test.tsx": 3315,
"src/renderer/src/components/task-page/linear/task-page-linear-in-orca-issues.test.ts": 430,
"src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx": 2217,
"src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx": 1814,
"src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx": 2810,
"src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx": 1018,
"src/renderer/src/components/task-page/linear/issues/task-page-linear-in-orca-issues.test.ts": 430,
"src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx": 1814,
"src/renderer/src/components/task-page/task-page-cache-selectors.test.ts": 108,
"src/renderer/src/components/task-page/task-page-initial-selection-scaling.test.tsx": 1953,
"src/renderer/src/components/task-page/task-page-work-item-pagination.test.ts": 63,
@@ -6940,7 +6930,6 @@
"tests/e2e/structured-attention-read-retirement.unit.test.ts": 6109,
"tests/e2e/structured-chat-tab-lifetime.unit.test.ts": 5616,
"tests/e2e/sustained-agent-typing-load-scripts.unit.test.ts": 2169,
"tests/e2e/task-page-linear-refresh-authority.unit.test.ts": 5548,
"tests/e2e/terminal-perf-presentation.unit.test.ts": 60,
"tests/e2e/worktree-status-warning.unit.test.ts": 2331,
"tests/tools/omp-close-lifecycle.test.mjs": 403,
@@ -77,7 +77,7 @@ const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([
'react-doctor(no-adjust-state-on-prop-change)',
new Set([
'src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts',
'src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts'
'src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts'
])
],
[
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "./tsconfig.web.json",
"exclude": [
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts",
"../src/shared/**/*.test.ts",
"../src/shared/**/*.test.tsx",
-1
View File
@@ -2,7 +2,6 @@
"extends": "./tsconfig.web.json",
"include": [
"../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts",
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/runtime/observed-provider-accounts.test.tsx",
"../src/renderer/src/components/status-bar/ClaudeSwitcherMenu.observation.test.tsx",
"../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts",
-1
View File
@@ -11,7 +11,6 @@
"../src/shared/**/*"
],
"exclude": [
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts"
],
"compilerOptions": {
+1 -1
View File
@@ -441,7 +441,7 @@
{
"files": [
"src/renderer/src/components/task-page/github/issue-creation/use-task-page-github-issue-draft.ts",
"src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts"
"src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts"
],
"rules": [
"react-doctor/no-adjust-state-on-prop-change"
@@ -1,119 +0,0 @@
import { useMemo, useRef } from 'react'
import { vi, type Mock } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { JiraIssue } from '../../../../shared/jira/jira-types'
import type { JiraSavedFilterState } from '../../../../shared/jira/jira-saved-filters'
import type { TaskPageLinearViewStateModel } from '../task-page/linear/use-task-page-linear-view-state'
import type { TaskPageLinearCollectionEffectsModel } from '../task-page/linear/use-task-page-linear-collection-effects'
import { useTaskPageJiraListState } from '../task-page/jira/use-task-page-jira-list-state'
import { useTaskPageJiraListEffects } from '../task-page/jira/use-task-page-jira-list-effects'
const favouriteTransport: {
catalogue: Mock
readSaved: Mock
mutateSaved: Mock
readQuick: Mock
list: Mock
search: Mock
resume: Mock
} = vi.hoisted(() => ({
catalogue: vi.fn(),
readSaved: vi.fn(),
mutateSaved: vi.fn(),
readQuick: vi.fn(),
list: vi.fn(),
search: vi.fn(),
resume: vi.fn()
}))
export { favouriteTransport }
vi.mock('@/runtime/jira/runtime-jira-favourite-filters-client', () => ({
jiraListFavouriteFiltersForTarget: favouriteTransport.catalogue
}))
vi.mock('@/runtime/jira/runtime-jira-saved-filter-state', () => ({
readJiraSavedFilterState: favouriteTransport.readSaved,
mutateJiraSavedFilters: favouriteTransport.mutateSaved
}))
vi.mock('@/runtime/jira/runtime-jira-quick-filter-state', () => ({
readJiraQuickFilterState: favouriteTransport.readQuick
}))
vi.mock('@/runtime/jira/runtime-jira-detail-client', () => ({
jiraListPrioritiesForTarget: vi.fn()
}))
vi.mock('@/runtime/jira/runtime-jira-client', () => ({
jiraGetProjectStatusOrder: vi.fn()
}))
export const favourite = {
id: 'fav-1',
name: 'My Jira favourite',
jql: 'filter = 12345 ORDER BY updated DESC',
siteId: 'site-a'
}
export const named = { id: 'named', name: 'Custom named filter', jql: 'project = CUSTOM' }
export const emptySaved: JiraSavedFilterState = { filters: [named], activeFilterId: null }
const emptyIssues: JiraIssue[] = []
const noop = () => {}
export function resetFavouriteTransport() {
vi.resetAllMocks()
favouriteTransport.catalogue.mockImplementation(async (_target, siteId: string) => ({
supported: true,
filters: [{ ...favourite, siteId }],
truncated: false
}))
favouriteTransport.readSaved.mockResolvedValue(emptySaved)
favouriteTransport.mutateSaved.mockImplementation(async (_target, mutation) => ({
...emptySaved,
activeFilterId: mutation.kind === 'select' ? mutation.id : null
}))
favouriteTransport.readQuick.mockResolvedValue({ statuses: [] })
favouriteTransport.list.mockResolvedValue(emptyIssues)
favouriteTransport.search.mockResolvedValue(emptyIssues)
}
export type FavouriteFixtureProps = { hostId?: ExecutionHostId; siteId?: string; enabled?: boolean }
export function useFavouriteTaskPage({
hostId = 'runtime:owner-a',
siteId = 'site-a',
enabled = true
}: FavouriteFixtureProps = {}) {
const sourceContext = useMemo(
() => ({
kind: 'task-source' as const,
provider: 'jira' as const,
projectId: 'folder-project',
hostId,
providerIdentity: { provider: 'jira' as const, siteId: siteId === 'all' ? null : siteId }
}),
[hostId, siteId]
)
const model = {
accountBackedTaskSourceHostId: hostId,
jiraTaskSourceContext: sourceContext,
jiraTaskSourceScopeKey: JSON.stringify([hostId, siteId]),
taskSource: enabled ? 'jira' : 'github',
taskResumeApplied: true,
jiraConnected: true,
selectedJiraSiteId: siteId,
repos: [],
selectedRepos: [],
pageData: {},
settings: { activeRuntimeEnvironmentId: 'different-focused-server' },
jiraSearchPersistReadyRef: useRef(true),
selectedJiraIssueKey: null,
selectedJiraIssueFallback: null,
setSelectedJiraIssueKey: noop,
setSelectedJiraIssueFallback: noop,
setTaskResumeState: favouriteTransport.resume,
linkedJiraIssueRefs: [],
linkedJiraIssueRefsSignature: '[]',
listJiraIssues: favouriteTransport.list,
searchJiraIssues: favouriteTransport.search
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual Jira hooks only read the supplied fields from the preceding provider pipeline.
const state = useTaskPageJiraListState(model as unknown as TaskPageLinearViewStateModel)
const effects = { ...state, displayedJiraIssues: state.jiraIssues }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All fields read by the actual Jira list effect are supplied; unrelated provider stages are omitted.
useTaskPageJiraListEffects(effects as unknown as TaskPageLinearCollectionEffectsModel)
return state
}
@@ -3,7 +3,7 @@ import {
buildJiraIssueKeyJql,
buildJiraTextMatchJql
} from '../../../shared/jira/jira-search-input-jql'
import { getJiraBadRequestReason } from './task-page/jira/task-page-jira-load-state'
import { getJiraBadRequestReason } from './task-page/jira/listing/task-page-jira-load-state'
export async function searchJiraIssuesByKeyOrText(
input: string,
@@ -1,9 +1,9 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageFrame } from './Frame'
import { TaskPageGitHubIssueDialog } from './github/issue-creation/IssueDialog'
import { TaskPageLinearProjectDialog } from './linear/ProjectDialog'
import { TaskPageLinearIssueDialog } from './linear/IssueDialog'
import { TaskPageJiraIssueDialog } from './jira/IssueDialog'
import { TaskPageLinearProjectDialog } from './linear/creation/ProjectDialog'
import { TaskPageLinearIssueDialog } from './linear/creation/IssueDialog'
import { TaskPageJiraIssueDialog } from './jira/creation/IssueDialog'
import { TaskPageGitLabDialog } from './gitlab/Dialog'
import { TaskPageLinearConnectDialog } from './linear/ConnectDialog'
import { TaskPageJiraConnectDialog } from './jira/ConnectDialog'
@@ -14,14 +14,14 @@ import { useTaskPageJiraListState } from './jira/use-task-page-jira-list-state'
import { useTaskPageResumeRestoration } from './use-task-page-resume-restoration'
import { useTaskPageProviderMetadata } from './use-task-page-provider-metadata'
import { useTaskPageGitLabLoading } from './gitlab/use-task-page-gitlab-loading'
import { useTaskPageLinearListSelection } from './linear/use-task-page-linear-list-selection'
import { useTaskPageLinearListProjection } from './linear/use-task-page-linear-list-projection'
import { useTaskPageLinearBoard } from './linear/use-task-page-linear-board'
import { useTaskPageJiraListProjection } from './jira/use-task-page-jira-list-projection'
import { useTaskPageLinearCreationState } from './linear/use-task-page-linear-creation-state'
import { useTaskPageLinearListSelection } from './linear/issues/use-task-page-linear-list-selection'
import { useTaskPageLinearListProjection } from './linear/issues/use-task-page-linear-list-projection'
import { useTaskPageLinearBoard } from './linear/issues/use-task-page-linear-board'
import { useTaskPageJiraListProjection } from './jira/listing/use-task-page-jira-list-projection'
import { useTaskPageLinearCreationState } from './linear/creation/use-task-page-linear-creation-state'
import { useTaskPageGitHubMutationState } from './github/mutations/use-task-page-github-mutation-state'
import { useTaskPageJiraCreationState } from './jira/use-task-page-jira-creation-state'
import { useTaskPageJiraCreationMetadata } from './jira/use-task-page-jira-creation-metadata'
import { useTaskPageJiraCreationState } from './jira/creation/use-task-page-jira-creation-state'
import { useTaskPageJiraCreationMetadata } from './jira/creation/use-task-page-jira-creation-metadata'
import { useTaskPageGitHubListProjection } from './github/listing/use-task-page-github-list-projection'
import { useTaskPageGitHubSearchPagination } from './github/listing/use-task-page-github-search-pagination'
import { useTaskPageGitHubLandingRefresh } from './github/refresh/use-task-page-github-landing-refresh'
@@ -29,15 +29,15 @@ import { useTaskPageGitHubQuietRefresh } from './github/refresh/use-task-page-gi
import { useTaskPageSearchActions } from './use-task-page-search-actions'
import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions'
import { useTaskPageGitHubIssueCreation } from './github/issue-creation/use-task-page-github-issue-creation'
import { useTaskPageLinearProjectCreation } from './linear/use-task-page-linear-project-creation'
import { useTaskPageLinearIssueCreation } from './linear/use-task-page-linear-issue-creation'
import { useTaskPageJiraIssueCreation } from './jira/use-task-page-jira-issue-creation'
import { useTaskPageJiraSummaryGeneration } from './jira/use-task-page-jira-summary-generation'
import { useTaskPageLinearProjectCreation } from './linear/creation/use-task-page-linear-project-creation'
import { useTaskPageLinearIssueCreation } from './linear/creation/use-task-page-linear-issue-creation'
import { useTaskPageJiraIssueCreation } from './jira/creation/use-task-page-jira-issue-creation'
import { useTaskPageJiraSummaryGeneration } from './jira/creation/use-task-page-jira-summary-generation'
import { useTaskPageGlobalEffects } from './use-task-page-global-effects'
import { useTaskPageLinearListEffects } from './linear/use-task-page-linear-list-effects'
import { useTaskPageLinearInOrcaEffects } from './linear/use-task-page-linear-in-orca-effects'
import { useTaskPageLinearCollectionEffects } from './linear/use-task-page-linear-collection-effects'
import { useTaskPageJiraListEffects } from './jira/use-task-page-jira-list-effects'
import { useTaskPageLinearListEffects } from './linear/issues/use-task-page-linear-list-effects'
import { useTaskPageLinearInOrcaEffects } from './linear/issues/use-task-page-linear-in-orca-effects'
import { useTaskPageLinearCollectionEffects } from './linear/projects/use-task-page-linear-collection-effects'
import { useTaskPageJiraListEffects } from './jira/listing/use-task-page-jira-list-effects'
import { useTaskPageComposerActions } from './use-task-page-composer-actions'
import { TaskPageSurface } from './Surface'
@@ -28,7 +28,7 @@ import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page/github/
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/class-names'
import { CircleDot, ChevronDown, Copy, CheckCircle2, Ban, ChevronRight } from 'lucide-react'
import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model'
import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types'
import { TaskPageGitHubDuplicatePicker } from './DuplicatePicker'
import { useGitHubDuplicateIssueCandidates } from '@/components/github/github-duplicate-issue-candidates'
@@ -16,7 +16,7 @@ import { githubProjectHost } from '../../../../../../shared/github/projects/proj
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/class-names'
import { LoaderCircle, ChevronDown, Check } from 'lucide-react'
import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model'
import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types'
import { GitHubAssigneeAvatar } from './Avatars'
import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
import { codeHostErrorText } from '@/lib/source-control/remote/code-host-error-copy'
@@ -1,5 +1,5 @@
import { useAppStore } from '@/store/index'
import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata'
import type { TaskPageJiraCreationMetadataModel } from '../../jira/creation/use-task-page-jira-creation-metadata'
// Why the list projection's input model: the landing run's later-stage model carries these same fields.
type LandingRequestInput = Pick<
@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'
import { useGitHubTaskItems } from '@/hooks/useGitHubTaskItems'
import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata'
import type { TaskPageJiraCreationMetadataModel } from '../../jira/creation/use-task-page-jira-creation-metadata'
import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types'
import { taskPageGitHubItemKey } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-keys'
import { deriveAdvertisedTotalPages } from '../../../../../../shared/work-items/work-item-empty-page'
@@ -32,3 +32,26 @@ export type BeginTaskPageGitHubWorkItemMutationResult = {
families: string[]
key: TaskPageGitHubMutationKey
}
export type TaskPageGitHubWorkItemMutationRunner = {
run: (input: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
mutate: () => Promise<{
ok?: boolean
error?:
| string
| {
message?: string
}
} | void>
successToast?: string
errorToast: string
}) => Promise<'confirmed' | 'rolled_back' | 'stale'>
isIntentPending: (input: {
item: GitHubWorkItem
intent: TaskPageGitHubMutationIntent
sourceContext?: TaskSourceContext | null
}) => boolean
}
@@ -1,4 +1,4 @@
import type { TaskPageLinearCreationStateModel } from '../../linear/use-task-page-linear-creation-state'
import type { TaskPageLinearCreationStateModel } from '../../linear/creation/use-task-page-linear-creation-state'
import { getGitHubTaskKind } from '../../../../../../shared/work-items/task-github-search'
import { useMemo, useLayoutEffect, useEffect, useCallback, useRef } from 'react'
import { parseTaskQuery } from '../../../../../../shared/work-items/task-query'
@@ -25,7 +25,7 @@ import {
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/class-names'
import { LoaderCircle, GitMerge, ChevronDown, ExternalLink } from 'lucide-react'
import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model'
import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types'
import { resolveTaskPullRequestRepo } from './task-page-github-review-model'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
export function PRMergeCell({
@@ -20,7 +20,7 @@ import {
import { cn } from '@/lib/class-names'
import { ChevronDown } from 'lucide-react'
import { Popover, PopoverTrigger } from '@/components/ui/popover'
import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model'
import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types'
import {
mergeReviewerSuggestions,
resolveTaskPullRequestRepo
@@ -15,7 +15,7 @@ import type { GitHubRepositoryIdentity as GitHubOwnerRepo } from '../../../../..
import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context'
import type { TaskPageGitHubWorkItemMutationRunner } from '../../linear/task-page-linear-jira-list-model'
import type { TaskPageGitHubWorkItemMutationRunner } from '../mutations/task-page-github-work-item-mutation-types'
import { buildRequestedReviewUsers } from './task-page-github-review-model'
type ReviewerActionsInput = {
@@ -25,7 +25,7 @@ import {
} from '../../../../../../shared/github/checks/complete-pr-check-owner'
import { getWorkItemRepo } from '@/lib/work-items/work-item-repo-row'
import type { GitHubWorkItem } from '../../../../../../shared/github/work-items/work-item-types'
import type { TaskPageJiraCreationMetadataModel } from '../../jira/use-task-page-jira-creation-metadata'
import type { TaskPageJiraCreationMetadataModel } from '../../jira/creation/use-task-page-jira-creation-metadata'
import { deriveTaskPagePRCheckSummary } from '../../task-page-pr-check-summary'
import { PR_CHECKS_EAGER_PREFETCH_LIMIT } from '../../task-page-source-context'
import { getTaskPageRepoSourceContext } from '../../../../lib/work-items/task-repo-source-context'
@@ -7,14 +7,14 @@ import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { jiraListGridStyle } from '../../jira-list-columns'
import { JiraListColumnsMenu } from '../../jira-list-columns-menu'
import { TaskPageJiraSortControls } from './task-page-jira-sort-controls'
import { TaskPageJiraErrorBanner } from '../linear/task-page-linear-jira-list-model'
import { TaskPageJiraTextFallbackNotice } from './task-page-jira-text-fallback-notice'
import { TaskPageJiraIssueList } from '@/components/task-page/jira/task-page-jira-issue-list'
import { TaskPageJiraBoardView } from '@/components/task-page/jira/task-page-jira-board-view'
import { TaskPageJiraSortControls } from './listing/task-page-jira-sort-controls'
import { TaskPageJiraErrorBanner } from './listing/task-page-jira-error-banner'
import { TaskPageJiraTextFallbackNotice } from './listing/task-page-jira-text-fallback-notice'
import { TaskPageJiraIssueList } from '@/components/task-page/jira/listing/task-page-jira-issue-list'
import { TaskPageJiraBoardView } from '@/components/task-page/jira/boards/task-page-jira-board-view'
import { TaskPageJiraViewControls } from '@/components/task-page/jira/task-page-jira-view-controls'
import { formatRelativeTime } from '../task-page-source-context'
import { getJiraStatusTone } from '@/components/task-page/jira/task-page-jira-status-tone'
import { getJiraStatusTone } from '@/components/task-page/jira/listing/task-page-jira-status-tone'
import JiraIssueWorkspace from '@/components/JiraIssueWorkspace'
import { TaskPageLinearContent } from '../linear/Content'
import {
@@ -1,6 +1,6 @@
import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters'
import { TaskPageJiraSavedFilters } from './TaskPageJiraSavedFilters'
import { TaskPageJiraFavouriteFilters } from './TaskPageJiraFavouriteFilters'
import { TaskPageJiraStatusFilters } from './filters/TaskPageJiraStatusFilters'
import { TaskPageJiraSavedFilters } from './filters/TaskPageJiraSavedFilters'
import { TaskPageJiraFavouriteFilters } from './filters/TaskPageJiraFavouriteFilters'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { cn } from '@/lib/class-names'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
@@ -1,68 +0,0 @@
// @vitest-environment happy-dom
import type { ComponentProps } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { TaskPageJiraIssueAssigneeField } from './IssueAssigneeField'
const search = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/jira/runtime-jira-user-fields-client', () => ({
jiraListAssignableUsersForProject: search
}))
afterEach(cleanup)
it('drops old provider candidates before another host with identical project IDs can select them', async () => {
const onSelect = vi.fn()
const model: ComponentProps<typeof TaskPageJiraIssueAssigneeField>['model'] = {
settings: null,
jiraTaskSourceContext: null,
jiraStatusCurrent: true,
jiraStatus: {
connected: true,
activeSiteId: 'same-site',
viewer: { accountId: 'local-me', displayName: 'Local me', email: null }
},
jiraCreateFields: [{ key: 'assignee', name: 'Assignee', required: false }],
newJiraIssueTargetProject: { id: '100', key: 'PRJ', name: 'Project', siteId: 'same-site' },
newJiraIssueAssignee: null,
setNewJiraIssueAssignee: onSelect,
newJiraIssueSubmitting: false,
providerRuntimeContextKey: 'local'
}
search.mockResolvedValueOnce([{ accountId: 'local-user', displayName: 'Local user' }])
const { rerender } = render(<TaskPageJiraIssueAssigneeField model={model} />)
fireEvent.click(screen.getByRole('button', { name: 'Assignee' }))
await screen.findByRole('button', { name: 'Local user' })
expect(screen.getByRole('button', { name: 'Assign to me (Local me)' })).toBeTruthy()
expect(search).toHaveBeenCalledWith(
{ kind: 'default-scope', settings: null },
'PRJ',
'',
'same-site'
)
search.mockReturnValueOnce(new Promise(() => {}))
rerender(
<TaskPageJiraIssueAssigneeField
model={{ ...model, providerRuntimeContextKey: 'runtime:remote', jiraStatusCurrent: false }}
/>
)
expect(screen.queryByRole('button', { name: 'Local user' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Assignee' }))
await waitFor(() => expect(search).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('button', { name: 'Local user' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Assign to me (Local me)' })).toBeNull()
rerender(
<TaskPageJiraIssueAssigneeField
model={{
...model,
providerRuntimeContextKey: 'runtime:remote',
jiraStatus: {
...model.jiraStatus,
viewer: { accountId: 'remote-me', displayName: 'Remote me', email: null }
}
}}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Assign to me (Remote me)' }))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'remote-me' }))
})
@@ -8,13 +8,13 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { useTranslate } from '@/i18n/use-translate'
import { JiraTransitionFieldsForm } from '../../jira-transition-fields-form'
import { classifyTransitionRequirements } from '../../../../../shared/jira/jira-transition-fields'
import { JiraTransitionFieldsForm } from '../../../jira-transition-fields-form'
import { classifyTransitionRequirements } from '../../../../../../shared/jira/jira-transition-fields'
import type {
JiraIssue,
JiraIssueUpdate,
JiraTransition
} from '../../../../../shared/jira/jira-types'
} from '../../../../../../shared/jira/jira-types'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
export type JiraBoardMovePrompt = {
@@ -3,9 +3,9 @@ import type {
JiraIssueUpdate,
JiraMutationResult,
JiraTransition
} from '../../../../../shared/jira/jira-types'
} from '../../../../../../shared/jira/jira-types'
import type { TaskPageJiraBoardSection } from './task-page-jira-board-sections'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import {
findJiraBoardSectionTransitions,
loadTaskPageJiraIssueTransitions
@@ -1,5 +1,5 @@
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types'
import { groupJiraIssuesByStatus } from './task-page-jira-issue-list'
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types'
import { groupJiraIssuesByStatus } from '../listing/task-page-jira-issue-list'
export type TaskPageJiraBoardSection = {
key: string
@@ -1,5 +1,5 @@
import type { JiraIssue, JiraTransition } from '../../../../../shared/jira/jira-types'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type { JiraIssue, JiraTransition } from '../../../../../../shared/jira/jira-types'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import { jiraListTransitionsForTarget } from '@/runtime/jira/runtime-jira-detail-client'
import { getRuntimeTargetHostId } from '@/runtime/rpc/targets/runtime-client-target'
import { createMetadataRequestStore, loadMetadata } from '@/hooks/metadata-request-cache'
@@ -3,21 +3,21 @@ import { useAppStore } from '@/store/index'
import { getEnvironmentRuntimeContextKey } from '@/runtime/hosts/observations/provider-runtime-context'
import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target'
import { useTranslate } from '@/i18n/use-translate'
import { classifyTransitionRequirements } from '../../../../../shared/jira/jira-transition-fields'
import { classifyTransitionRequirements } from '../../../../../../shared/jira/jira-transition-fields'
import type {
JiraIssue,
JiraIssueUpdate,
JiraProjectStatusOrder,
JiraTransition
} from '../../../../../shared/jira/jira-types'
import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
} from '../../../../../../shared/jira/jira-types'
import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import { TaskPageJiraBoard, jiraBoardIssueRefKey } from './task-page-jira-board'
import type { TaskPageJiraBoardSection } from './task-page-jira-board-sections'
import { JiraBoardMoveDialog, type JiraBoardMovePrompt } from './task-page-jira-board-move-dialog'
import { applyJiraBoardMove, getJiraBoardMoveTransitions } from './task-page-jira-board-move'
import { formatRelativeTime } from '../task-page-source-context'
import { enqueueJiraIssueMutation, jiraIssueRequestScope } from '../../jira-issue-mutation-queue'
import { formatRelativeTime } from '../../task-page-source-context'
import { enqueueJiraIssueMutation, jiraIssueRequestScope } from '../../../jira-issue-mutation-queue'
type Props = {
issues: JiraIssue[]
@@ -17,7 +17,7 @@ import {
writeJiraBoardIssueDragData
} from '@/lib/work-items/jira/jira-board-drag-payload'
import type { JiraBoardIssueDragRef } from '@/lib/work-items/jira/jira-board-drag-payload'
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types'
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types'
import {
buildJiraBoardSections,
type TaskPageJiraBoardSection
@@ -1,8 +1,8 @@
import { useCallback } from 'react'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { getJiraSelfUser } from '@/components/jira-self-user'
import { JiraUserPicker, type JiraUserPickerFixedOption } from '@/components/jira-user-picker'
import { hasJiraAssigneeCreateField } from '@/components/task-page/jira/task-page-jira-create-fields'
import { hasJiraAssigneeCreateField } from '@/components/task-page/jira/creation/task-page-jira-create-fields'
import { getJiraProjectSelectionKey } from '@/components/task-page/jira/task-page-jira-project-selection'
import { jiraListAssignableUsersForProject } from '@/runtime/jira/runtime-jira-user-fields-client'
import { useTranslate } from '@/i18n/use-translate'
@@ -1,4 +1,4 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import {
Dialog,
DialogContent,
@@ -34,7 +34,7 @@ import { Input } from '@/components/ui/input'
import {
getJiraCreateAllowedValueLabel,
isJiraScalarUserCreateField
} from '@/components/task-page/jira/task-page-jira-create-fields'
} from '@/components/task-page/jira/creation/task-page-jira-create-fields'
import { TaskPageJiraIssueAssigneeField } from './IssueAssigneeField'
import { TaskPageJiraIssueUserField } from './IssueUserField'
export function TaskPageJiraIssueDialog({
@@ -1,4 +1,4 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useTranslate } from '@/i18n/use-translate'
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { JiraUserPicker } from '@/components/jira-user-picker'
import type { RowLessSource as RuntimeJiraSettings } from '@/runtime/hosts/selection/default-creation-host'
import type { JiraUser } from '../../../../../shared/jira/jira-types'
import type { JiraUser } from '../../../../../../shared/jira/jira-types'
export function TaskPageJiraIssueUserField({
providerSettings,
@@ -1,5 +1,5 @@
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
import type { JiraCreateField } from '../../../../../shared/jira/jira-types'
import type { JiraCreateField } from '../../../../../../shared/jira/jira-types'
// 'assignee' is system here because the dialog renders a dedicated picker for it.
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set([
@@ -1,9 +1,9 @@
import { useJiraCreateAssignee } from '../../use-jira-create-assignee'
import { useJiraCreateAssignee } from '../../../use-jira-create-assignee'
import React, { useCallback, useEffect, useMemo } from 'react'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import { filterJiraProjectPickerProjects } from '@/components/jira-project-picker-filter'
import { isVisibleJiraCreateField } from '@/components/task-page/jira/task-page-jira-create-fields'
import { isVisibleJiraCreateField } from '@/components/task-page/jira/creation/task-page-jira-create-fields'
import {
jiraProjectDisplayLabelComparator,
getJiraProjectSelectionKey
@@ -1,8 +1,8 @@
import type { TaskPageGitHubMutationStateModel } from '../github/mutations/use-task-page-github-mutation-state'
import type { TaskPageGitHubMutationStateModel } from '../../github/mutations/use-task-page-github-mutation-state'
import { useState, useRef, useEffect } from 'react'
import type { JiraIssueType, JiraCreateField } from '../../../../../shared/jira/jira-types'
import type { JiraIssueType, JiraCreateField } from '../../../../../../shared/jira/jira-types'
import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention'
import { writeNewJiraIssueDraft } from '../task-page-draft-storage'
import { writeNewJiraIssueDraft } from '../../task-page-draft-storage'
import { useTaskPageJiraCreationProjects } from './use-task-page-jira-creation-projects'
export function useTaskPageJiraCreationStatePrelude(model: TaskPageGitHubMutationStateModel) {
const {
@@ -2,12 +2,12 @@ import {
taskProviderErrorMessage,
taskProviderErrorText
} from '@/lib/work-items/task-provider-error-copy'
import type { TaskPageLinearIssueCreationModel } from '../linear/use-task-page-linear-issue-creation'
import type { TaskPageLinearIssueCreationModel } from '../../linear/creation/use-task-page-linear-issue-creation'
import { useCallback } from 'react'
import {
buildJiraCreateSubmission,
hasJiraAssigneeCreateField
} from '@/components/task-page/jira/task-page-jira-create-fields'
} from '@/components/task-page/jira/creation/task-page-jira-create-fields'
import { jiraCreateIssue } from '@/runtime/jira/runtime-jira-user-fields-client'
import { jiraGetIssue } from '@/runtime/jira/runtime-jira-client'
import { toast } from 'sonner'
@@ -2,11 +2,11 @@
import { act, useState } from 'react'
import { renderHook, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraIssueSummaryGenerationResult } from '../../../../../shared/jira/jira-issue-summary-generation'
import type { JiraIssueSummaryGenerationResult } from '../../../../../../shared/jira/jira-issue-summary-generation'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId
} from '../../../../../shared/execution-host'
} from '../../../../../../shared/execution-host'
import { useTaskPageJiraSummaryGeneration } from './use-task-page-jira-summary-generation'
const mocks = vi.hoisted(() => ({ generate: vi.fn(), cancel: vi.fn(), error: vi.fn() }))
@@ -3,16 +3,16 @@ import {
taskProviderErrorText
} from '@/lib/work-items/task-provider-error-copy'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../shared/non-secure-context-uuid'
import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../../shared/non-secure-context-uuid'
import { isWebClientLocation } from '@/app-shell/platform/web-client-location'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target'
import {
generateRuntimeJiraIssueSummary,
cancelRuntimeJiraIssueSummary
} from '@/runtime/jira/runtime-jira-summary-generation-client'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type { JiraIssueSummaryGenerationRequest } from '../../../../../shared/jira/jira-issue-summary-generation'
import { getTaskSourceCacheScope } from '../../../../../shared/work-items/task-source-context'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import type { JiraIssueSummaryGenerationRequest } from '../../../../../../shared/jira/jira-issue-summary-generation'
import { getTaskSourceCacheScope } from '../../../../../../shared/work-items/task-source-context'
import { translate } from '@/i18n/i18n'
import type { TaskPageJiraIssueCreationModel } from './use-task-page-jira-issue-creation'
@@ -10,7 +10,7 @@ import {
CommandItem,
CommandEmpty
} from '@/components/ui/command'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
export function TaskPageJiraFavouriteFilters({
model
@@ -1,4 +1,4 @@
import { isUtf8ByteLengthWithinLimit } from '../../../../../shared/utf8-byte-limits'
import { isUtf8ByteLengthWithinLimit } from '../../../../../../shared/utf8-byte-limits'
import { useState } from 'react'
import { Check, ListFilter, Pencil, Plus, Trash2 } from 'lucide-react'
import {
@@ -6,7 +6,7 @@ import {
MAX_JIRA_SAVED_FILTER_NAME_LENGTH,
MAX_JIRA_SAVED_FILTERS,
type JiraSavedFilter
} from '../../../../../shared/jira/jira-saved-filters'
} from '../../../../../../shared/jira/jira-saved-filters'
import { useTranslate } from '@/i18n/use-translate'
import {
Command,
@@ -2,13 +2,13 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { useTranslate } from '@/i18n/use-translate'
import { buildJiraPresetJql } from '../../../../../shared/jira/jira-preset-jql'
import { buildJiraPresetJql } from '../../../../../../shared/jira/jira-preset-jql'
import {
getJiraStatusCatalogScopeKey,
includeSelectedJiraStatuses
} from './task-page-jira-status-catalog'
import { restrictJiraQueryToProject } from '../../../../../shared/jira/jira-project-reference'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { restrictJiraQueryToProject } from '../../../../../../shared/jira/jira-project-reference'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
export function TaskPageJiraStatusFilters({
model
@@ -1,6 +1,6 @@
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
import type { JiraProjectReference } from '../../../../../shared/jira/jira-project-reference'
import { getJiraProjectSelectionKey } from './task-page-jira-project-selection'
import type { JiraIssue } from '../../../../../../shared/jira/jira-types'
import type { JiraProjectReference } from '../../../../../../shared/jira/jira-project-reference'
import { getJiraProjectSelectionKey } from '../task-page-jira-project-selection'
export function getJiraStatusCatalogScopeKey(
sourceScopeKey: string,
@@ -4,8 +4,8 @@ import {
jiraListFavouriteFiltersForTarget,
type JiraFavouriteFiltersResult
} from '@/runtime/jira/runtime-jira-favourite-filters-client'
import type { JiraFavouriteFilter } from '../../../../../shared/jira/jira-favourite-filters'
import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state'
import type { JiraFavouriteFilter } from '../../../../../../shared/jira/jira-favourite-filters'
import type { TaskPageLinearViewStateModel } from '../../linear/use-task-page-linear-view-state'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
@@ -3,7 +3,7 @@ import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store/index'
import type { Repo } from '../../../../../shared/repo-types'
import type { Repo } from '../../../../../../shared/repo-types'
import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope'
const { readSavedFilters, mutateSavedFilters, readQuickFilters, writeQuickFilters } = vi.hoisted(
@@ -1,9 +1,12 @@
import { useCallback, useState } from 'react'
import type { Repo } from '../../../../../shared/repo-types'
import type { JiraProjectReference } from '../../../../../shared/jira/jira-project-reference'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host'
import { resolveJiraSourceHostId } from '../../../../../shared/jira/jira-source-host'
import { getRepoHostIdentity } from '../../../../../shared/repo-host-identity'
import type { Repo } from '../../../../../../shared/repo-types'
import type { JiraProjectReference } from '../../../../../../shared/jira/jira-project-reference'
import {
getRepoExecutionHostId,
type ExecutionHostId
} from '../../../../../../shared/execution-host'
import { resolveJiraSourceHostId } from '../../../../../../shared/jira/jira-source-host'
import { getRepoHostIdentity } from '../../../../../../shared/repo-host-identity'
export function useTaskPageJiraProjectScope(
repos: readonly Repo[],
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
import type { JiraQuickFilterState } from '../../../../../../shared/jira/jira-quick-filters'
import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host'
const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() }))
vi.mock('@/runtime/jira/runtime-jira-quick-filter-state', () => ({
readJiraQuickFilterState: read,
writeJiraQuickFilterState: write
}))
beforeEach(() => {
vi.resetAllMocks()
})
afterEach(cleanup)
function model(
host: string | null,
accountHost = 'account-owner'
): Parameters<typeof useTaskPageJiraQuickFilters>[0] {
return {
accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost),
taskSource: 'jira',
taskResumeApplied: true,
jiraTaskSourceContext: host
? {
kind: 'task-source',
provider: 'jira',
projectId: 'project',
hostId: toRuntimeExecutionHostId(host)
}
: null
}
}
it('ignores a late mutation reply after changing Jira execution hosts', async () => {
read
.mockResolvedValueOnce({ statuses: ['Blocked'] })
.mockResolvedValueOnce({ statuses: ['Open'] })
let finish: (value: JiraQuickFilterState) => void = () => {}
write.mockReturnValue(
new Promise((resolve) => {
finish = resolve
})
)
const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
initialProps: { host: 'a' }
})
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = result.current.selectJiraStatuses(['In Progress'])
})
rerender({ host: 'b' })
await waitFor(() => expect(result.current.selectedJiraStatuses).toEqual(['Open']))
await act(async () => {
finish({ statuses: ['In Progress'] })
await pending
})
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
})
@@ -7,8 +7,8 @@ import {
import {
normalizeJiraQuickFilterState,
type JiraQuickFilterState
} from '../../../../../shared/jira/jira-quick-filters'
import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state'
} from '../../../../../../shared/jira/jira-quick-filters'
import type { TaskPageLinearViewStateModel } from '../../linear/use-task-page-linear-view-state'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host'
const { read, mutate } = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn() }))
vi.mock('@/runtime/jira/runtime-jira-saved-filter-state', () => ({
readJiraSavedFilterState: read,
mutateJiraSavedFilters: mutate
}))
afterEach(cleanup)
beforeEach(() => {
vi.resetAllMocks()
})
const filter = { id: 'mine', name: 'My work', jql: 'project = APP' }
const initial = { filters: [filter], activeFilterId: filter.id }
function model(
host: string | null,
accountHost = 'account-owner'
): Parameters<typeof useTaskPageJiraSavedFilters>[0] {
return {
taskSource: 'jira',
taskResumeApplied: true,
accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost),
jiraTaskSourceContext: host
? {
kind: 'task-source',
provider: 'jira',
projectId: 'project',
hostId: toRuntimeExecutionHostId(host)
}
: null
}
}
it('ignores an old host read that completes after switching execution hosts', async () => {
let resolveOld: (value: typeof initial) => void = () => {}
read
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve
})
)
.mockResolvedValueOnce({ filters: [], activeFilterId: null })
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery),
{ initialProps: { host: 'host-a' } }
)
rerender({ host: 'host-b' })
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
await act(async () => {
resolveOld(initial)
})
expect(result.current.jiraSavedFilters).toEqual([])
expect(setQuery).not.toHaveBeenCalledWith(filter.jql)
})
@@ -1,4 +1,4 @@
import { createNonSecureContextUuid } from '../../../../../shared/non-secure-context-uuid'
import { createNonSecureContextUuid } from '../../../../../../shared/non-secure-context-uuid'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target'
import {
@@ -9,8 +9,8 @@ import type {
JiraSavedFilterMutation,
JiraSavedFilter,
JiraSavedFilterState
} from '../../../../../shared/jira/jira-saved-filters'
import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state'
} from '../../../../../../shared/jira/jira-saved-filters'
import type { TaskPageLinearViewStateModel } from '../../linear/use-task-page-linear-view-state'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
@@ -2,8 +2,8 @@ import type { CacheEntry } from '@/store/github/cache/cache-model'
import {
getTaskSourceCacheScope,
type TaskSourceContext
} from '../../../../../shared/work-items/task-source-context'
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
} from '../../../../../../shared/work-items/task-source-context'
import type { JiraIssue } from '../../../../../../shared/jira/jira-types'
type JiraIssueCache = Record<string, CacheEntry<JiraIssue>>
type JiraSearchCache = Record<string, CacheEntry<JiraIssue[]>>
@@ -0,0 +1,52 @@
import { useTranslate } from '../../../../i18n/use-translate'
import type { TaskPageJiraLoadError } from './task-page-jira-load-state'
import React from 'react'
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../ui/collapsible'
import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
import { Button } from '../../../ui/button'
export function TaskPageJiraErrorBanner({
error,
open,
onOpenChange
}: {
error: TaskPageJiraLoadError
open: boolean
onOpenChange: (open: boolean) => void
}): React.JSX.Element {
const t = useTranslate()
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
className="border-b border-border bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 flex-none" />
<div className="min-w-0 flex-1">
<div className="font-medium leading-5">{error.title}</div>
{error.details ? (
<>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="-ml-1 mt-1 h-6 px-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{t('auto.components.TaskPage.40eaf2c27c', 'Details')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 rounded-md border border-destructive/20 bg-background/80 px-2 py-1.5 font-mono text-xs text-foreground">
{error.details}
</div>
</CollapsibleContent>
</>
) : null}
</div>
</div>
</Collapsible>
)
}
@@ -4,12 +4,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { translate } from '@/i18n/i18n'
import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
import type { JiraIssue } from '../../../../../../shared/jira/jira-types'
import {
formatJiraEstimate,
formatJiraStoryPoints,
type JiraListColumnId
} from '../../jira-list-columns'
} from '../../../jira-list-columns'
import { getJiraPriorityTone } from './task-page-jira-status-tone'
const MUTED_CELL = 'block truncate text-[12px] text-muted-foreground'
@@ -6,12 +6,12 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import { JiraIssueRowCopyMenu } from '../../JiraIssueRowCopyMenu'
import { JiraLinkedWorkspaceAction } from '../../JiraLinkedWorkspaceAction'
import type { Worktree } from '../../../../../shared/worktree/types'
import { jiraListColumnVisibility, type JiraListColumn } from '../../jira-list-columns'
import { JiraIssueRowCopyMenu } from '../../../JiraIssueRowCopyMenu'
import { JiraLinkedWorkspaceAction } from '../../../JiraLinkedWorkspaceAction'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { jiraListColumnVisibility, type JiraListColumn } from '../../../jira-list-columns'
import { JiraIssueCell, JiraPriorityText, unassignedLabel } from './task-page-jira-issue-cells'
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types'
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
export type TaskPageJiraIssueSection = {
@@ -1,7 +1,7 @@
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
import type { JiraIssue } from '../../../../../../shared/jira/jira-types'
import type { LinkedJiraIssueRef } from '@/lib/work-items/jira/jira-issue-workspace-attachment'
import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context'
import { readLinkedTaskIssuesWithLimit } from '../../read-linked-task-issues'
import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context'
import { readLinkedTaskIssuesWithLimit } from '../../../read-linked-task-issues'
import { createTaskPageJiraLoadFailureState } from './task-page-jira-load-state'
import { translate } from '@/i18n/i18n'
@@ -1,5 +1,5 @@
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
import { parseJiraStatusError } from '../../../../../shared/jira/jira-status-error'
import type { JiraIssue } from '../../../../../../shared/jira/jira-types'
import { parseJiraStatusError } from '../../../../../../shared/jira/jira-status-error'
import { translate } from '@/i18n/i18n'
export type TaskPageJiraLoadError = {
@@ -1,6 +1,9 @@
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
import { buildJiraTextMatchJql, mayBeJql } from '../../../../../shared/jira/jira-search-input-jql'
import { searchJiraIssuesByKeyOrText } from '../../jira-key-or-text-search'
import type { JiraIssue } from '../../../../../../shared/jira/jira-types'
import {
buildJiraTextMatchJql,
mayBeJql
} from '../../../../../../shared/jira/jira-search-input-jql'
import { searchJiraIssuesByKeyOrText } from '../../../jira-key-or-text-search'
import { getJiraBadRequestReason } from './task-page-jira-load-state'
export type TaskPageJiraJqlRejection = {
@@ -15,8 +15,8 @@ import {
getJiraListColumnLabel,
jiraListColumnVisibility,
type JiraListColumn
} from '../../jira-list-columns'
import type { JiraIssueSortColumn, JiraIssueSortDirection } from '../../jira-issue-sorter'
} from '../../../jira-list-columns'
import type { JiraIssueSortColumn, JiraIssueSortDirection } from '../../../jira-issue-sorter'
type TaskPageJiraSortControlsProps = {
columns: readonly JiraListColumn[]
@@ -1,5 +1,5 @@
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../shared/jira/jira-types'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type { JiraIssue, JiraProjectStatusOrder } from '../../../../../../shared/jira/jira-types'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target'
import { getEnvironmentRuntimeContextKey } from '@/runtime/hosts/observations/provider-runtime-context'
import { jiraGetProjectStatusOrder } from '@/runtime/jira/runtime-jira-client'
@@ -1,26 +1,26 @@
import { translate } from '@/i18n/i18n'
import { isRuntimeProviderSearchQueryWithinLimit } from '@/runtime/issue-search/runtime-provider-search-bounds'
import { withCurrentSprintClause } from '../../../../../shared/jira/jira-jql-predicate'
import { withCurrentSprintClause } from '../../../../../../shared/jira/jira-jql-predicate'
import {
buildJiraStatusCatalog,
getJiraStatusCatalogScopeKey
} from './task-page-jira-status-catalog'
import type { TaskPageLinearCollectionEffectsModel } from '../linear/use-task-page-linear-collection-effects'
} from '../filters/task-page-jira-status-catalog'
import type { TaskPageLinearCollectionEffectsModel } from '../../linear/projects/use-task-page-linear-collection-effects'
import { useEffect, useMemo, useRef } from 'react'
import {
getSingleJiraProjectScope,
getTaskPageJiraStatusOrderScopeKey,
getTaskPageJiraStatusOrderRuntimeScopeKey,
loadTaskPageJiraProjectStatusOrder
} from '@/components/task-page/jira/task-page-jira-status-order'
import { createTaskPageJiraLoadFailureState } from '@/components/task-page/jira/task-page-jira-load-state'
import { searchTaskPageJiraIssues } from '@/components/task-page/jira/task-page-jira-search'
import { JIRA_ITEM_LIMIT } from '../task-page-source-context'
import { useTaskSearchDebouncedApply } from '../use-task-search-debounced-apply'
} from '@/components/task-page/jira/listing/task-page-jira-status-order'
import { createTaskPageJiraLoadFailureState } from '@/components/task-page/jira/listing/task-page-jira-load-state'
import { searchTaskPageJiraIssues } from '@/components/task-page/jira/listing/task-page-jira-search'
import { JIRA_ITEM_LIMIT } from '../../task-page-source-context'
import { useTaskSearchDebouncedApply } from '../../use-task-search-debounced-apply'
import { readLinkedJiraIssues } from './task-page-jira-linked-issues'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target'
import { buildJiraPresetJql } from '../../../../../shared/jira/jira-preset-jql'
import { restrictJiraQueryToProject } from '../../../../../shared/jira/jira-project-reference'
import { buildJiraPresetJql } from '../../../../../../shared/jira/jira-preset-jql'
import { restrictJiraQueryToProject } from '../../../../../../shared/jira/jira-project-reference'
export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffectsModel) {
const {
accountBackedTaskSourceHostId,
@@ -1,11 +1,11 @@
import type { TaskPageLinearBoardModel } from '../linear/use-task-page-linear-board'
import type { TaskPageLinearBoardModel } from '../../linear/issues/use-task-page-linear-board'
import { useMemo, useCallback } from 'react'
import { findTaskPageJiraIssue } from '@/components/task-page/jira/task-page-jira-cache-selectors'
import { findTaskPageJiraIssue } from '@/components/task-page/jira/listing/task-page-jira-cache-selectors'
import {
getSingleJiraProjectScope,
getTaskPageJiraStatusOrderScopeKey,
getTaskPageJiraStatusOrderRuntimeScopeKey
} from '@/components/task-page/jira/task-page-jira-status-order'
} from '@/components/task-page/jira/listing/task-page-jira-status-order'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target'
import {
buildJiraIssueWorkspaceAttachmentIndex,
@@ -13,7 +13,7 @@ import {
findJiraIssueWorkspaceAttachmentsInIndex
} from '@/lib/work-items/jira/jira-issue-workspace-attachment'
import { filterLinkedJiraIssues } from './task-page-jira-linked-issues'
import { sortJiraIssues } from '../../jira-issue-sorter'
import { sortJiraIssues } from '../../../jira-issue-sorter'
export function useTaskPageJiraListProjection(model: TaskPageLinearBoardModel) {
const {
accountBackedTaskSourceHostId,
@@ -1,93 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraIssue, JiraStatus, JiraTransition } from '../../../../../shared/jira/jira-types'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
const mocks = vi.hoisted(() => ({
jiraListTransitionsForTarget: vi.fn()
}))
vi.mock('@/runtime/jira/runtime-jira-detail-client', () => ({
jiraListTransitionsForTarget: mocks.jiraListTransitionsForTarget
}))
vi.mock('@/runtime/jira/runtime-jira-detail-client', () => ({
jiraListTransitionsForTarget: mocks.jiraListTransitionsForTarget
}))
const { loadTaskPageJiraIssueTransitions } = await import('./task-page-jira-board-transitions')
const target: RuntimeClientTarget = { kind: 'local' }
function status(id: string, name: string): JiraStatus {
return { id, name, categoryKey: 'indeterminate', categoryName: 'In Progress' }
}
function transition(id: string, to: JiraStatus): JiraTransition {
return { id, name: `To ${to.name}`, to }
}
function issue(key: string, statusValue: JiraStatus, siteId?: string): JiraIssue {
return {
id: key,
key,
siteId,
title: key,
url: `https://example.atlassian.net/browse/${key}`,
project: { id: 'p1', key: 'STA', name: 'Stably' },
issueType: { id: 't1', name: 'Task' },
status: statusValue,
labels: [],
updatedAt: '2026-08-07T12:00:00.000Z',
createdAt: '2026-08-07T12:00:00.000Z'
}
}
describe('loadTaskPageJiraIssueTransitions', () => {
beforeEach(() => {
mocks.jiraListTransitionsForTarget.mockReset()
})
it('surfaces a failed transitions request and allows an explicit retry', async () => {
mocks.jiraListTransitionsForTarget.mockRejectedValue(new Error('offline'))
await expect(
loadTaskPageJiraIssueTransitions(
target,
'scope',
issue('STA-500', status('2', 'In Progress'))
)
).rejects.toThrow('offline')
mocks.jiraListTransitionsForTarget.mockResolvedValue([])
await expect(
loadTaskPageJiraIssueTransitions(
target,
'scope',
issue('STA-500', status('2', 'In Progress')),
true
)
).resolves.toEqual([])
expect(mocks.jiraListTransitionsForTarget).toHaveBeenCalledTimes(2)
})
it('keeps concurrent local and remote metadata apart even with the same scope', async () => {
const remote: RuntimeClientTarget = { kind: 'environment', environmentId: 'owner' }
const currentIssue = issue('STA-OWNER', status('2', 'In Progress'), 'site-a')
const localAction = transition('local-done', status('3', 'Done'))
const remoteAction = transition('remote-done', status('3', 'Done'))
mocks.jiraListTransitionsForTarget.mockImplementation((owner: RuntimeClientTarget) =>
Promise.resolve(owner.kind === 'local' ? [localAction] : [remoteAction])
)
await expect(
Promise.all([
loadTaskPageJiraIssueTransitions(target, 'same-scope', currentIssue),
loadTaskPageJiraIssueTransitions(remote, 'same-scope', currentIssue)
])
).resolves.toEqual([[localAction], [remoteAction]])
await expect(
loadTaskPageJiraIssueTransitions(remote, 'same-scope', currentIssue)
).resolves.toEqual([remoteAction])
expect(mocks.jiraListTransitionsForTarget).toHaveBeenCalledTimes(2)
expect(mocks.jiraListTransitionsForTarget).toHaveBeenCalledWith(
remote,
currentIssue.key,
currentIssue.siteId
)
})
})
@@ -1,89 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it } from 'vitest'
import {
favouriteTransport as transport,
favourite,
named,
emptySaved,
resetFavouriteTransport,
useFavouriteTaskPage,
type FavouriteFixtureProps
} from '../../__mocks__/task-page-jira-favourite-fixture'
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
resetFavouriteTransport()
})
async function ready(result: { current: ReturnType<typeof useFavouriteTaskPage> }) {
act(() => result.current.loadJiraFavouriteFilters())
await waitFor(() => {
expect(result.current.jiraSavedFiltersLoading).toBe(false)
expect(result.current.jiraFavouriteFiltersLoading).toBe(false)
expect(result.current.jiraLoading).toBe(false)
})
}
it('waits for confirmed named deselection before changing query or linked mode', async () => {
transport.readSaved.mockResolvedValue({ ...emptySaved, activeFilterId: named.id })
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
act(() => result.current.setJiraHasWorkspace(true))
const deferred = Promise.withResolvers<typeof emptySaved>()
transport.mutateSaved.mockReturnValueOnce(deferred.promise)
let applying = Promise.resolve(false)
act(() => {
applying = result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0])
})
expect(result.current.jiraFavouriteFilter).toBeNull()
expect(result.current.jiraSearchInput).toBe(named.jql)
expect(result.current.jiraHasWorkspace).toBe(true)
await act(async () => {
deferred.resolve(emptySaved)
expect(await applying).toBe(true)
})
expect(result.current.jiraHasWorkspace).toBe(false)
expect(result.current.jiraSearchInput).toBe('')
expect(result.current.jiraFavouriteFilter?.id).toBe(favourite.id)
expect(transport.mutateSaved).toHaveBeenCalledExactlyOnceWith(
{ kind: 'environment', environmentId: 'owner-a' },
{ kind: 'select', id: null }
)
})
it.each(['site', 'owner', 'disabled'] as const)(
'fences a delayed handoff across %s A→B→A',
async (kind) => {
transport.readSaved.mockResolvedValue({ ...emptySaved, activeFilterId: named.id })
const { result, rerender } = renderHook(
(props: FavouriteFixtureProps) => useFavouriteTaskPage(props),
{
initialProps: {}
}
)
await ready(result)
const deferred = Promise.withResolvers<typeof emptySaved>()
transport.mutateSaved.mockReturnValueOnce(deferred.promise)
let applying = Promise.resolve(false)
act(() => {
applying = result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0])
})
const other: FavouriteFixtureProps = {
hostId: kind === 'owner' ? 'runtime:owner-b' : 'runtime:owner-a',
siteId: kind === 'site' ? 'site-b' : 'site-a',
enabled: kind !== 'disabled'
}
rerender(other)
rerender({ hostId: 'runtime:owner-a', siteId: 'site-a', enabled: true })
await ready(result)
await act(async () => {
deferred.resolve(emptySaved)
expect(await applying).toBe(false)
})
expect(result.current.jiraFavouriteFilter).toBeNull()
expect(result.current.jiraSearchInput).not.toContain('12345')
}
)
@@ -1,93 +0,0 @@
import { defaultJiraListColumnIds, visibleJiraListColumns } from '../../jira-list-columns'
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TaskPageJiraIssueList } from './task-page-jira-issue-list'
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
const { jiraGetProjectStatusOrderMock } = vi.hoisted(() => ({
jiraGetProjectStatusOrderMock: vi.fn()
}))
vi.mock('@/runtime/jira/runtime-jira-client', () => ({
jiraGetProjectStatusOrder: (...args: unknown[]) => jiraGetProjectStatusOrderMock(...args)
}))
afterEach(() => {
cleanup()
vi.restoreAllMocks()
jiraGetProjectStatusOrderMock.mockReset()
})
function jiraIssue(
key: string,
title: string,
statusId: string,
statusName: string,
options: { priority?: string; projectKey?: string; siteId?: string } = {}
): JiraIssue {
const siteId = options.siteId ?? 'site-1'
const projectKey = options.projectKey ?? 'ALP'
return {
id: `${siteId}:${key}`,
key,
title,
url: `https://example.atlassian.net/browse/${key}`,
siteId,
siteName: 'Example Jira',
project: { id: projectKey, key: projectKey, name: projectKey, siteId },
issueType: { id: '10001', name: 'Bug' },
status: { id: statusId, name: statusName, categoryKey: 'new', categoryName: statusName },
priority: options.priority ? { id: options.priority, name: options.priority } : undefined,
labels: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
}
describe('Jira issue status grouping', () => {
it('collapses and expands a status group through its accessible trigger', async () => {
const user = userEvent.setup()
render(
React.createElement(
TooltipProvider,
null,
React.createElement(TaskPageJiraIssueList, {
columns: visibleJiraListColumns(defaultJiraListColumnIds()),
formatUpdatedAt: () => 'today',
getStatusTone: () => 'border-border',
issues: [
jiraIssue('ALP-1', 'First issue', '1', 'To Do'),
jiraIssue('ALP-2', 'Second issue', '1', 'To Do')
],
onOpenIssue: vi.fn(),
onStartWorkspace: vi.fn(),
selectedIssue: null,
showSiteContext: false,
statusOrder: null
})
)
)
const trigger = screen.getByRole('button', { name: 'To Do 2' })
expect(trigger).toHaveAttribute('data-variant', 'ghost')
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('First issue')).toBeInTheDocument()
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('First issue')).not.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('First issue')).toBeInTheDocument()
})
})
@@ -1,122 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TaskPageJiraIssueList } from './task-page-jira-issue-list'
import { defaultJiraListColumnIds, visibleJiraListColumns } from '../../jira-list-columns'
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
const issue: JiraIssue = {
id: '1',
key: 'APP-1',
title: 'Fix login',
url: 'https://acme.atlassian.net/browse/APP-1',
project: { id: '1', key: 'APP', name: 'App' },
issueType: { id: '1', name: 'Bug' },
status: { id: '1', name: 'Open', categoryKey: 'new', categoryName: 'Open' },
labels: [],
createdAt: '2026-10-01',
updatedAt: '2026-10-01'
}
const onOpenIssue = vi.fn()
function renderList() {
return render(
<TooltipProvider>
<TaskPageJiraIssueList
columns={visibleJiraListColumns(defaultJiraListColumnIds())}
issues={[issue]}
selectedIssue={null}
showSiteContext={false}
statusOrder={null}
formatUpdatedAt={() => 'Today'}
getStatusTone={() => ''}
onOpenIssue={onOpenIssue}
onStartWorkspace={vi.fn()}
/>
</TooltipProvider>
)
}
beforeEach(() => {
Object.assign(window, { api: { ui: { writeClipboardText } } })
vi.clearAllMocks()
localStorage.clear()
})
afterEach(cleanup)
describe('Jira row quick copy', () => {
it('offers the same copy actions in the row context menu', async () => {
renderList()
const user = userEvent.setup()
await user.pointer({ keys: '[MouseRight]', target: screen.getByText('Fix login') })
await user.click(screen.getByRole('menuitem', { name: 'Copy URL' }))
expect(writeClipboardText).toHaveBeenCalledWith(issue.url)
expect(onOpenIssue).not.toHaveBeenCalled()
})
it('renders a template preview with additional instructions and copies it', async () => {
renderList()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' }))
await user.click(screen.getByRole('menuitem', { name: 'Copy prompt from template' }))
await user.type(
screen.getByLabelText('Additional instructions (optional)'),
'Cover SSH behavior.'
)
const preview = screen.getByLabelText('Preview')
expect(preview).toBeInstanceOf(HTMLTextAreaElement)
if (!(preview instanceof HTMLTextAreaElement)) {
throw new Error('Expected a textarea preview')
}
expect(preview.value).toContain('Cover SSH behavior.')
await user.click(screen.getByRole('button', { name: 'Copy prompt' }))
expect(writeClipboardText).toHaveBeenCalledWith(expect.stringContaining('APP-1'))
expect(writeClipboardText).toHaveBeenCalledWith(expect.stringContaining('Cover SSH behavior.'))
expect(
screen.queryByRole('dialog', { name: 'Copy prompt from template' })
).not.toBeInTheDocument()
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Copy context for APP-1' })).toHaveFocus()
)
})
it('reuses a custom template after closing and reopening the picker', async () => {
renderList()
const user = userEvent.setup()
async function openCustomTemplate() {
await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' }))
await user.click(screen.getByRole('menuitem', { name: 'Copy prompt from template' }))
await user.click(screen.getByRole('combobox', { name: 'Template' }))
await user.click(screen.getByRole('option', { name: 'Custom template' }))
}
await openCustomTemplate()
await user.clear(screen.getByLabelText('Template text'))
await user.type(screen.getByLabelText('Template text'), 'Review $jira-issue')
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await openCustomTemplate()
expect(screen.getByLabelText('Template text')).toHaveValue('Review $jira-issue')
await user.click(screen.getByRole('button', { name: 'Copy prompt' }))
expect(writeClipboardText).toHaveBeenCalledWith(
`Review Complete Jira issue APP-1: Fix login\n\n${issue.url}`
)
})
it('keeps the template picker open when the clipboard write fails', async () => {
writeClipboardText.mockRejectedValueOnce(new Error('Clipboard unavailable'))
renderList()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' }))
await user.click(screen.getByRole('menuitem', { name: 'Copy prompt from template' }))
await user.click(screen.getByRole('button', { name: 'Copy prompt' }))
expect(screen.getByRole('dialog', { name: 'Copy prompt from template' })).toBeInTheDocument()
})
it('copies the key from the row without opening the issue', async () => {
renderList()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Copy context for APP-1' }))
await user.click(screen.getByRole('menuitem', { name: 'Copy key' }))
expect(writeClipboardText).toHaveBeenCalledWith('APP-1')
expect(onOpenIssue).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,7 @@
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
import type { JiraStatusCatalogItem } from './task-page-jira-status-catalog'
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
import { useTaskPageJiraFavouriteFilters } from './use-task-page-jira-favourite-filters'
import { useTaskPageJiraQuickFilters } from './filters/use-task-page-jira-quick-filters'
import type { JiraStatusCatalogItem } from './filters/task-page-jira-status-catalog'
import { useTaskPageJiraSavedFilters } from './filters/use-task-page-jira-saved-filters'
import { useTaskPageJiraFavouriteFilters } from './filters/use-task-page-jira-favourite-filters'
import type { JiraFavouriteFilter } from '../../../../../shared/jira/jira-favourite-filters'
import type { JiraSavedFilter } from '../../../../../shared/jira/jira-saved-filters'
import type { TaskPageLinearViewStateModel } from '../linear/use-task-page-linear-view-state'
@@ -11,8 +11,8 @@ import type {
JiraProjectStatusOrder,
JiraPriority
} from '../../../../../shared/jira/jira-types'
import type { TaskPageJiraLoadError } from '@/components/task-page/jira/task-page-jira-load-state'
import type { TaskPageJiraJqlRejection } from '@/components/task-page/jira/task-page-jira-search'
import type { TaskPageJiraLoadError } from '@/components/task-page/jira/listing/task-page-jira-load-state'
import type { TaskPageJiraJqlRejection } from '@/components/task-page/jira/listing/task-page-jira-search'
import type { JiraPresetId } from '@/components/task-page/task-page-localized-options'
import type {
JiraIssueSortColumn,
@@ -35,7 +35,7 @@ import {
} from '@/runtime/rpc/targets/runtime-client-target'
import { getEnvironmentRuntimeContextKey } from '@/runtime/hosts/observations/provider-runtime-context'
import { getTaskSourceCacheScope } from '../../../../../shared/work-items/task-source-context'
import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope'
import { useTaskPageJiraProjectScope } from './filters/use-task-page-jira-project-scope'
const EMPTY_JIRA_PRIORITIES: JiraPrioritiesBySite = new Map()
export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
@@ -1,114 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
import type { JiraQuickFilterState } from '../../../../../shared/jira/jira-quick-filters'
import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host'
const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() }))
vi.mock('@/runtime/jira/runtime-jira-quick-filter-state', () => ({
readJiraQuickFilterState: read,
writeJiraQuickFilterState: write
}))
beforeEach(() => {
vi.resetAllMocks()
})
afterEach(cleanup)
function model(
host: string | null,
accountHost = 'account-owner'
): Parameters<typeof useTaskPageJiraQuickFilters>[0] {
return {
accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost),
taskSource: 'jira',
taskResumeApplied: true,
jiraTaskSourceContext: host
? {
kind: 'task-source',
provider: 'jira',
projectId: 'project',
hostId: toRuntimeExecutionHostId(host)
}
: null
}
}
it('ignores a late mutation reply after changing Jira execution hosts', async () => {
read
.mockResolvedValueOnce({ statuses: ['Blocked'] })
.mockResolvedValueOnce({ statuses: ['Open'] })
let finish: (value: JiraQuickFilterState) => void = () => {}
write.mockReturnValue(
new Promise((resolve) => {
finish = resolve
})
)
const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
initialProps: { host: 'a' }
})
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = result.current.selectJiraStatuses(['In Progress'])
})
rerender({ host: 'b' })
await waitFor(() => expect(result.current.selectedJiraStatuses).toEqual(['Open']))
await act(async () => {
finish({ statuses: ['In Progress'] })
await pending
})
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
})
it.each(['success', 'failure'] as const)(
'keeps a new owner visit pending after an old %s settlement',
async (outcome) => {
read.mockResolvedValue({ statuses: ['Open'] })
let finishOld: (state: JiraQuickFilterState) => void = () => {}
let failOld: (reason: Error) => void = () => {}
let finishNew: (state: JiraQuickFilterState) => void = () => {}
write
.mockReturnValueOnce(
new Promise((resolve, reject) => {
finishOld = resolve
failOld = reject
})
)
.mockReturnValueOnce(
new Promise((resolve) => {
finishNew = resolve
})
)
const { result, rerender } = renderHook(
({ host }) => useTaskPageJiraQuickFilters(model(host)),
{ initialProps: { host: 'a' } }
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
let oldWrite = Promise.resolve()
act(() => {
oldWrite = result.current.selectJiraStatuses(['Closed']).catch(() => {})
})
rerender({ host: 'b' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
rerender({ host: 'a' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
let newWrite = Promise.resolve()
act(() => {
newWrite = result.current.selectJiraStatuses(['In Progress'])
})
await act(async () => {
if (outcome === 'success') {
finishOld({ statuses: ['Closed'] })
} else {
failOld(new Error('Old request failed'))
}
await oldWrite
})
expect(result.current.jiraQuickFiltersPending).toBe(true)
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
expect(result.current.jiraQuickFiltersError).toBeNull()
await act(async () => {
finishNew({ statuses: ['In Progress'] })
await newWrite
})
expect(result.current.selectedJiraStatuses).toEqual(['In Progress'])
expect(result.current.jiraQuickFiltersPending).toBe(false)
}
)
@@ -1,149 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { startTransition, Suspense } from 'react'
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host'
const { read, mutate } = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn() }))
vi.mock('@/runtime/jira/runtime-jira-saved-filter-state', () => ({
readJiraSavedFilterState: read,
mutateJiraSavedFilters: mutate
}))
afterEach(cleanup)
beforeEach(() => {
vi.resetAllMocks()
})
const filter = { id: 'mine', name: 'My work', jql: 'project = APP' }
const initial = { filters: [filter], activeFilterId: filter.id }
function model(
host: string | null,
accountHost = 'account-owner'
): Parameters<typeof useTaskPageJiraSavedFilters>[0] {
return {
taskSource: 'jira',
taskResumeApplied: true,
accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost),
jiraTaskSourceContext: host
? {
kind: 'task-source',
provider: 'jira',
projectId: 'project',
hostId: toRuntimeExecutionHostId(host)
}
: null
}
}
it('ignores an old host read that completes after switching execution hosts', async () => {
let resolveOld: (value: typeof initial) => void = () => {}
read
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve
})
)
.mockResolvedValueOnce({ filters: [], activeFilterId: null })
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery),
{ initialProps: { host: 'host-a' } }
)
rerender({ host: 'host-b' })
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
await act(async () => {
resolveOld(initial)
})
expect(result.current.jiraSavedFilters).toEqual([])
expect(setQuery).not.toHaveBeenCalledWith(filter.jql)
})
it.each(['success', 'failure'] as const)(
'keeps a new visit to the owner pending after an old write settles with %s',
async (settlement) => {
read.mockResolvedValue(initial)
let settleOld = () => {}
let settleNew = () => {}
mutate
.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
settleOld = () =>
settlement === 'success'
? resolve({ filters: [], activeFilterId: null })
: reject(new Error('Old host disconnected'))
})
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
settleNew = () => resolve(initial)
})
)
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery),
{ initialProps: { host: 'host-a' } }
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
let oldWrite = Promise.resolve(false)
act(() => {
oldWrite = result.current.applyJiraSavedFilter(filter).catch(() => false)
})
rerender({ host: 'host-b' })
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
rerender({ host: 'host-a' })
await waitFor(() => expect(read).toHaveBeenCalledTimes(3))
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
let newWrite = Promise.resolve(false)
act(() => {
newWrite = result.current.applyJiraSavedFilter(filter)
})
expect(result.current.jiraSavedFiltersPending).toBe(true)
setQuery.mockClear()
await act(async () => {
settleOld()
await oldWrite
})
expect(result.current.jiraSavedFilters).toEqual([filter])
expect(result.current.jiraSavedFiltersPending).toBe(true)
expect(result.current.jiraSavedFiltersError).toBeNull()
expect(setQuery).not.toHaveBeenCalled()
await act(async () => {
settleNew()
await newWrite
})
expect(result.current.jiraSavedFiltersPending).toBe(false)
expect(setQuery).toHaveBeenCalledWith(filter.jql)
}
)
it('applies only the committed owner while another owner render is suspended', async () => {
read.mockResolvedValue(initial)
mutate.mockResolvedValue(initial)
const setQuery = vi.fn()
const suspended = new Promise(() => {})
const { result, rerender } = renderHook(
({ host, suspend }) => {
const filters = useTaskPageJiraSavedFilters(model(host), setQuery)
if (suspend) {
throw suspended
}
return filters
},
{
initialProps: { host: 'host-a', suspend: false },
wrapper: ({ children }) => <Suspense fallback={null}>{children}</Suspense>
}
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
const committedAction = result.current.applyJiraSavedFilter
act(() => {
startTransition(() => rerender({ host: 'host-b', suspend: true }))
})
await act(async () => {
expect(await committedAction(filter)).toBe(true)
})
expect(mutate).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'host-a' },
{ kind: 'select', id: filter.id }
)
expect(read).toHaveBeenCalledTimes(1)
})
@@ -4,11 +4,11 @@ import { LoaderCircle } from 'lucide-react'
import { LinearIcon } from '@/components/task-page/task-page-localized-options'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { TaskPageLinearProjectOverview } from './ProjectOverview'
import { TaskPageLinearProjectList } from './ProjectList'
import { TaskPageLinearCustomViewList } from './CustomViewList'
import { TaskPageLinearCustomViewProjects } from './CustomViewProjects'
import { TaskPageLinearIssueList } from './IssueList'
import { TaskPageLinearProjectOverview } from './projects/ProjectOverview'
import { TaskPageLinearProjectList } from './projects/ProjectList'
import { TaskPageLinearCustomViewList } from './views/CustomViewList'
import { TaskPageLinearCustomViewProjects } from './views/CustomViewProjects'
import { TaskPageLinearIssueList } from './issues/IssueList'
export function TaskPageLinearContent({
model
}: {
@@ -1,9 +0,0 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { TaskPageLinearIssueBoard } from './IssueBoard'
export function TaskPageLinearIssueRows({
model
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
return <TaskPageLinearIssueBoard model={model} />
}
@@ -1,83 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TaskPageLinearIssueTable } from './IssueTable'
import type { LinearIssueListRow } from './task-page-linear-issue-model'
vi.mock('./task-page-linear-issue-model', () => ({ LinearStateCell: () => null }))
afterEach(cleanup)
function issue(id: string): LinearIssue {
return {
id,
identifier: `COR-${id}`,
title: `Issue ${id}`,
url: `https://linear.app/core/issue/COR-${id}`,
state: { name: 'Todo', type: 'unstarted', color: '#000' },
team: { id: 'core', name: 'Core', key: 'COR' },
labels: [],
labelIds: [],
priority: 0,
updatedAt: '2026-10-01T00:00:00Z'
}
}
const first = issue('1')
const second = issue('2')
const third = issue('3')
const rows: LinearIssueListRow[] = [
{ type: 'section', key: 'status:Todo', label: 'Todo', count: 2 },
{ type: 'issue', issue: first },
{ type: 'issue', issue: second },
{ type: 'section', key: 'status:Done', label: 'Done', count: 1 },
{ type: 'issue', issue: third }
]
function table(linearIssueListRows = rows) {
const fixture = {
selectedLinearWorkspaceId: 'all',
linearTaskSourceContext: null,
selectedLinearIssueId: null,
openLinearDetailPage: vi.fn(),
linearIssueAttachmentIndex: new Map(),
effectiveLinearDisplayProperties: new Set(),
linearIssueGridStyle: {},
linearIssueListRows,
handleOpenOrUseLinearItem: vi.fn()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every model field read by this table.
const model = fixture as unknown as TaskPageComposerActionsModel
return (
<TooltipProvider>
<TaskPageLinearIssueTable model={model} />
</TooltipProvider>
)
}
it('tracks the group key through refreshed rows and leaves ungrouped issues visible', async () => {
const user = userEvent.setup()
const view = render(table())
await user.click(screen.getByRole('button', { name: 'Todo 2' }))
const refreshed: LinearIssueListRow[] = [
...rows.slice(3),
{ type: 'section', key: 'status:Todo', label: 'Todo', count: 3 },
...rows.slice(1, 3),
{ type: 'issue', issue: issue('4') }
]
view.rerender(table(refreshed))
expect(screen.getByRole('button', { name: 'Todo 3', expanded: false })).toBeTruthy()
expect(screen.queryByText('Issue 4')).toBeNull()
expect(screen.getByText('Issue 3')).toBeTruthy()
view.rerender(table([{ type: 'issue', issue: first }]))
expect(screen.getByText('Issue 1')).toBeTruthy()
view.unmount()
render(table())
expect(screen.getByRole('button', { name: 'Todo 2', expanded: true })).toBeTruthy()
})
@@ -1,4 +1,4 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { useTranslate } from '@/i18n/use-translate'
import { linearLabelCountLabel } from './linear-attribute-count-labels'
@@ -1,5 +1,5 @@
import { ImeInput } from '@/lib/text-input/ime/ime-text-field'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { isScreenSubmitShortcut } from '@/lib/keyboard/screen-submit-shortcut'
import { useTranslate } from '@/i18n/use-translate'
@@ -1,4 +1,4 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import {
Dialog,
DialogContent,
@@ -1,5 +1,5 @@
import { ImeInput } from '@/lib/text-input/ime/ime-text-field'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { useTranslate } from '@/i18n/use-translate'
import { linearLabelCountLabel, linearMemberCountLabel } from './linear-attribute-count-labels'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
@@ -1,4 +1,4 @@
import type { TaskPageJiraListProjectionModel } from '../jira/use-task-page-jira-list-projection'
import type { TaskPageJiraListProjectionModel } from '../../jira/listing/use-task-page-jira-list-projection'
import { useState, useMemo, useEffect, useReducer, type SetStateAction } from 'react'
import { useTeamMembers, useTeamLabels, useTeamStates } from '@/hooks/useIssueMetadata'
import {
@@ -10,9 +10,9 @@ import {
completeLinearIssueForm,
handoffLinearIssueForm as handoff
} from '@/components/use-task-creation-draft-retention'
import { useNewLinearIssueProjects } from '../../use-new-linear-issue-projects'
import { useNewLinearIssueProjects } from '../../../use-new-linear-issue-projects'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { writeNewLinearIssueDraft, writeNewLinearProjectDraft } from '../task-page-draft-storage'
import { writeNewLinearIssueDraft, writeNewLinearProjectDraft } from '../../task-page-draft-storage'
import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host'
type FormAction = (value: LinearIssueForm, target: string) => LinearIssueForm
export function useLinearIssueCreationForm(
@@ -6,7 +6,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { linearCreateIssue } from '@/runtime/linear/runtime-linear-issue-client'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
export function useTaskPageLinearIssueCreation(model: TaskPageLinearProjectCreationModel) {
@@ -2,7 +2,7 @@ import {
taskProviderErrorMessage,
taskProviderErrorText
} from '@/lib/work-items/task-provider-error-copy'
import type { TaskPageGitHubIssueCreationModel } from '../github/issue-creation/use-task-page-github-issue-creation'
import type { TaskPageGitHubIssueCreationModel } from '../../github/issue-creation/use-task-page-github-issue-creation'
import { useCallback } from 'react'
import { linearCreateProject } from '@/runtime/linear/runtime-linear-project-client'
import { toast } from 'sonner'
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
// @vitest-environment happy-dom
import { reads, task, store } from './task-page-linear-refresh-test-fixtures'
import '@/store/slices/linear/linear-issue-read'
import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write'
import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host'
import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { act } from '@testing-library/react'
import { deferred } from '../../../../store/slices/linear/linear-slice-test-harness'
const cacheTime = Date.parse('2026-01-01T00:00:00Z')
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(cacheTime)
})
afterEach(() => vi.useRealTimers())
it('captured default runtime cannot seed or patch the focused foreign runtime cache', async () => {
const s = store()
const pending = deferred<{ ok: boolean }>()
const saving = writeLinearIssue(
task(),
{ state: task('Done').state },
defaultScopeSource(null),
() => pending.promise
)
// SAFETY: only activeRuntimeEnvironmentId is read by this captured-scope fixture.
s.setState({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the real consumer's exercised fields and actions; unrelated full-page/provider services are intentionally absent.
settings: { activeRuntimeEnvironmentId: 'remote' } as never,
linearIssueCache: { 'w::same-uuid': { data: task('Remote'), fetchedAt: cacheTime } }
})
const remote = s.getState().fetchLinearIssue(task().id, 'w').acquire()
expect(remote.currentView().data?.state.name).toBe('Remote')
reads.detail.mockResolvedValue(task('Local observation'))
const lease = s
.getState()
.fetchLinearIssue(task().id, 'w', {
scope: getLinearSourceScope({ kind: 'local' }),
force: true
})
.acquire()
expect(lease.currentView().data).toBeNull()
expect((await lease.waitForCurrentTerminal()).data?.state.name).toBe('Done')
await act(async () => {
pending.resolve({ ok: false })
await saving
})
expect(s.getState().linearIssueCache['w::same-uuid'].data?.state.name).toBe('Remote')
expect(remote.currentView().kind).toBe('data')
expect(remote.currentView().data?.state.name).toBe('Remote')
remote.release()
lease.release()
})
@@ -0,0 +1,210 @@
import type * as ReactModule from 'react'
import type { LinearEditState } from '../../../linear-item-drawer-types'
import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope'
import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host'
import { LinearIssueTextEditor } from '../../../LinearIssueTextEditor'
import { useLinearIssueEditController } from '../../../linear-item-drawer-edit-controller'
import type { LinearIssue } from '../../../../../../shared/linear/issue-types'
import { useStore } from 'zustand'
import { afterEach, beforeEach, vi, type Mock } from 'vitest'
import { cleanup } from '@testing-library/react'
import { createTestStore, issue } from '../../../../store/slices/linear/linear-slice-test-harness'
import { clearLinearRequestMaps } from '../../../../store/slices/linear/linear-slice-request-state'
const deferredIssueSetters = vi.hoisted(() => {
const updates: VoidFunction[] = []
return { enabled: false, updates }
})
vi.mock('react', async (original) => {
const actual = await original<typeof ReactModule>()
return {
...actual,
useReducer<S, A>(reducer: (previous: S, action: A) => S, initial: S) {
const [value, dispatch] = actual.useReducer(reducer, initial)
const send = actual.useCallback((action: A) => {
if (
deferredIssueSetters.enabled &&
typeof initial === 'object' &&
initial !== null &&
'whole' in initial
) {
deferredIssueSetters.updates.push(() => dispatch(action))
} else {
dispatch(action)
}
}, [])
return [value, send] as const
}
}
})
export { deferredIssueSetters }
const active = vi.hoisted(() => ({ store: vi.fn<() => Store>() }))
vi.mock('@/store/index', () => ({
useAppStore: Object.assign(
(selector: (state: ReturnType<Store['getState']>) => unknown) =>
useStore(active.store(), selector),
{
getState: () => active.store().getState(),
subscribe: (...args: Parameters<Store['subscribe']>) => active.store().subscribe(...args)
}
)
}))
vi.mock('@/i18n/i18n', async (original) => ({
...(await original<Record<string, unknown>>()),
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
fallback.replace(/{{(\w+)}}/g, (match, key) => values?.[key] ?? match)
}))
const reads: {
list: Mock
search: Mock
project: Mock
view: Mock
detail: Mock
update: Mock
create: Mock
projects: Mock
connect: Mock
test: Mock
select: Mock
} = vi.hoisted(() => ({
list: vi.fn(),
search: vi.fn(),
project: vi.fn(),
view: vi.fn(),
detail: vi.fn(),
update: vi.fn(),
create: vi.fn(),
projects: vi.fn(),
connect: vi.fn(),
test: vi.fn(),
select: vi.fn()
}))
const accountViewer = vi.hoisted(() => ({
displayName: 'User',
email: null,
organizationName: 'Acme'
}))
vi.mock('@/runtime/linear/runtime-linear-client', async (original) => ({
...(await original<Record<string, unknown>>()),
linearTestConnection: (...args: unknown[]) => reads.test(...args),
linearSelectWorkspace: (...args: unknown[]) => reads.select(...args),
linearDisconnect: async () => {},
linearConnect: (...args: unknown[]) => reads.connect(...args),
linearStatus: async () => ({ connected: true, viewer: accountViewer, selectedWorkspaceId: 'w' }),
linearListIssues: (...args: unknown[]) => reads.list(...args),
linearSearchIssues: (...args: unknown[]) => reads.search(...args)
}))
vi.mock('@/runtime/linear/runtime-linear-project-client', async (original) => ({
...(await original<Record<string, unknown>>()),
linearListProjects: reads.projects,
linearTeamStates: async () => [{ id: 'done', name: 'Done', type: 'completed', color: '#888888' }],
linearListProjectIssues: (...args: unknown[]) => reads.project(...args),
linearListCustomViewIssues: (...args: unknown[]) => reads.view(...args)
}))
vi.mock('@/runtime/linear/runtime-linear-issue-client', async (original) => ({
...(await original<Record<string, unknown>>()),
linearGetIssue: reads.detail,
linearIssueComments: async () => [],
linearUpdateIssue: reads.update,
linearCreateIssue: reads.create
}))
vi.mock('@/hooks/useIssueMetadata', async (original) => ({
...(await original<Record<string, unknown>>()),
useTeamLabels: () => ({ data: [{ id: 'bug', name: 'Bug' }], loading: false }),
useTeamMembers: () => ({ data: [], loading: false }),
clearLinearMetadataCache: vi.fn(),
useTeamStates: () => ({
data: [{ id: 'done', name: 'Done', type: 'completed', color: '#888888' }],
loading: false
})
}))
vi.mock('@/store/slices/worktrees/listing/use-worktree-catalog', async (original) => {
const empty: [] = []
return { ...(await original<Record<string, unknown>>()), useAllWorktrees: () => empty }
})
vi.mock('@/store/workspaces/catalog/workspace-record-fields', async (original) => {
const empty: [] = []
return { ...(await original<Record<string, unknown>>()), selectFolderWorkspaces: () => empty }
})
vi.mock('@/components/LinearIssueMarkdownDescriptionEditor', () => ({
LinearIssueMarkdownDescriptionEditor: () => null
}))
vi.mock('@/components/linear-item-drawer-sheet', () => ({
renderLinearItemDrawerSheet: (props: {
displayed: LinearIssue | null
editState: LinearEditState | null
}) =>
props.displayed && props.editState ? (
<DrawerFields row={props.displayed} editState={props.editState} />
) : (
<div />
)
}))
export function DrawerFields({ row, editState }: { row: LinearIssue; editState: LinearEditState }) {
const controller = useLinearIssueEditController({ issue: row, editState })
return (
<>
<output data-testid="drawer">
{row.state.name}:{editState.priority}:{editState.labels.join(',')}
</output>
<button onClick={() => controller.handlePriorityChange('4')}>Priority</button>
<button onClick={() => controller.handleLabelToggle('bug')}>Label</button>
<LinearIssueTextEditor issue={row} fields="title" />
</>
)
}
export function task(state = 'In Review') {
return {
...issue('same-uuid'),
identifier: 'DAT-124',
workspaceId: 'w',
state: { name: state, type: state === 'Done' ? 'completed' : 'started', color: '#888888' }
}
}
export function store() {
const result = createTestStore()
result.setState({
linearStatus: { connected: true, viewer: accountViewer, selectedWorkspaceId: 'w' },
linearStatusObserved: true,
linearStatusChecked: true,
linearStatusContextKey: getLinearSourceScope(defaultScopeSource(null)).runtimeContextKey
})
active.store.mockReturnValue(result)
return result
}
export type Store = ReturnType<typeof store>
beforeEach(() => {
deferredIssueSetters.enabled = false
deferredIssueSetters.updates.length = 0
vi.resetAllMocks()
reads.connect.mockResolvedValue({ ok: true, viewer: accountViewer })
reads.test.mockResolvedValue({ ok: true, viewer: accountViewer })
clearLinearRequestMaps()
})
afterEach(() => {
cleanup()
clearLinearRequestMaps()
})
export { reads }
@@ -1,4 +1,4 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { useTranslate } from '@/i18n/use-translate'
import {
resolveLinearIssueEmptyKind,
@@ -6,12 +6,12 @@ import {
} from './task-page-linear-issue-empty-state'
import { Button } from '@/components/ui/button'
import { LinearIcon } from '@/components/task-page/task-page-localized-options'
import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors'
import { isIntegrationCredentialDecryptionError } from '../../../../../../shared/integration-credential-errors'
import {
clampLinearIssueListLimit,
LINEAR_ISSUE_LIST_MAX
} from '../../../../../shared/linear/issue-read-limits'
import { LINEAR_ITEM_LIMIT } from '../task-page-source-context'
} from '../../../../../../shared/linear/issue-read-limits'
import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context'
import { TaskPageLinearIssueBoardColumns } from './IssueBoardColumns'
import { TaskPageLinearIssueTable } from './IssueTable'
export function TaskPageLinearIssueBoard({
@@ -1,4 +1,4 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { cn } from '@/lib/class-names'
import { findLinearIssueWorkspaceAttachmentInIndex } from '@/lib/work-items/linear/linear-issue-workspace-attachment'
import { getWorktreeAttachmentLabel } from '@/lib/workspace/display/worktree-attachment-label'
@@ -7,8 +7,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
import { Button } from '@/components/ui/button'
import { useTranslate } from '@/i18n/use-translate'
import { FolderOpen, ArrowRight, ExternalLink } from 'lucide-react'
import { LinearStateCell } from './task-page-linear-issue-model'
import { formatRelativeTime } from '../task-page-source-context'
import { LinearStateCell } from './task-page-linear-state-cell'
import { formatRelativeTime } from '../../task-page-source-context'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
export function TaskPageLinearIssueBoardColumns({
model
@@ -1,9 +1,9 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { TaskPageLinearIssueToolbar } from './IssueToolbar'
import { useTranslate } from '@/i18n/use-translate'
import { TaskPageLinearIssueRows } from './IssueRows'
import { TaskPageLinearIssueBoard } from './IssueBoard'
import { LinearCollectionNotice } from '@/components/linear-project-view-surfaces'
import { PaginationBar } from '../PaginationBar'
import { PaginationBar } from '../../PaginationBar'
export function TaskPageLinearIssueList({
model
}: {
@@ -62,7 +62,7 @@ export function TaskPageLinearIssueList({
</div>
) : null}
<TaskPageLinearIssueRows model={model} />
<TaskPageLinearIssueBoard model={model} />
{selectedLinearProject && linearProjectTab === 'issues' ? (
<>
<LinearCollectionNotice
@@ -1,14 +1,14 @@
import { useState } from 'react'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { ChevronDown, FolderOpen, ArrowRight, ExternalLink } from 'lucide-react'
import { findLinearIssueWorkspaceAttachmentInIndex } from '@/lib/work-items/linear/linear-issue-workspace-attachment'
import { getWorktreeAttachmentLabel } from '@/lib/workspace/display/worktree-attachment-label'
import { cn } from '@/lib/class-names'
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
import { LinearStateCell } from './task-page-linear-issue-model'
import { LinearStateCell } from './task-page-linear-state-cell'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { formatRelativeTime } from '../task-page-source-context'
import { formatRelativeTime } from '../../task-page-source-context'
import { Button } from '@/components/ui/button'
import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open'
export function TaskPageLinearIssueTable({
@@ -1,4 +1,4 @@
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import { Button } from '@/components/ui/button'
import { useTranslate } from '@/i18n/use-translate'
import { ChevronLeft, SlidersHorizontal, List, ArrowDownUp, Eye } from 'lucide-react'
@@ -18,7 +18,7 @@ import type {
LinearViewMode,
LinearGroupBy,
LinearOrderBy
} from '../../../../../shared/linear/issue-view-resume-state'
} from '../../../../../../shared/linear/issue-view-resume-state'
export function TaskPageLinearIssueToolbar({
model
}: {
@@ -1,6 +1,6 @@
import type { LinearCollectionResult } from '../../../../../shared/linear/workspace-types'
import { LINEAR_ISSUE_LIST_MAX } from '../../../../../shared/linear/issue-read-limits'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type { LinearCollectionResult } from '../../../../../../shared/linear/workspace-types'
import { LINEAR_ISSUE_LIST_MAX } from '../../../../../../shared/linear/issue-read-limits'
import type { LinearIssue } from '../../../../../../shared/linear/issue-types'
function linearIssueKey(issue: LinearIssue): string {
return JSON.stringify([issue.workspaceId, issue.id])
@@ -1,18 +1,18 @@
import { readLinkedTaskIssuesWithLimit } from '../../read-linked-task-issues'
import { readLinkedTaskIssuesWithLimit } from '../../../read-linked-task-issues'
import type {
LinearIssueRead,
LinearIssueLease,
LinearIssueView
} from '@/store/slices/linear/linear-issue-read-lifecycle'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type { LinearWorkspace } from '../../../../../shared/linear/workspace-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import { parseLinearIssueInput } from '../../../../../shared/linear/links'
import type { LinearIssue } from '../../../../../../shared/linear/issue-types'
import type { LinearWorkspace } from '../../../../../../shared/linear/workspace-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { parseLinearIssueInput } from '../../../../../../shared/linear/links'
import {
getTaskSourceCacheScope,
type TaskSourceContext
} from '../../../../../shared/work-items/task-source-context'
import { normalizeLinearIdentifier } from '../../../lib/work-items/linear/linear-issue-workspace-attachment'
} from '../../../../../../shared/work-items/task-source-context'
import { normalizeLinearIdentifier } from '../../../../lib/work-items/linear/linear-issue-workspace-attachment'
export type LinkedLinearIssueRef = {
identifier: string
@@ -1,7 +1,7 @@
import {
isEmptyLinearIssueAttributeFilter,
type LinearIssueAttributeFilter
} from '../../../../../shared/linear/issue-attribute-filter'
} from '../../../../../../shared/linear/issue-attribute-filter'
export type LinearIssueEmptyKind =
| 'context'
@@ -0,0 +1,109 @@
import type { LinearIssue } from '../../../../../../shared/linear/issue-types'
import type {
LinearOrderBy,
LinearGroupBy
} from '../../../../../../shared/linear/issue-view-resume-state'
import { getLinearPriorityLabel } from '../../task-page-localized-options'
import { translate } from '../../../../i18n/i18n'
import { compareNumericLocaleText } from '../../../../../../shared/locale-text-collators'
import type { Translate } from '../../../../i18n/use-translate'
export type LinearGroupSection = {
key: string
label: string
issues: LinearIssue[]
}
export function getLinearPriorityRank(priority: number): number {
return priority === 0 ? 5 : priority
}
export function compareLinearIssues(
a: LinearIssue,
b: LinearIssue,
orderBy: LinearOrderBy
): number {
if (orderBy === 'updated') {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}
if (orderBy === 'identifier') {
return compareNumericLocaleText(a.identifier, b.identifier)
}
const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority)
if (priorityDelta !== 0) {
return priorityDelta
}
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}
export function getLinearIssueGroup(
issue: LinearIssue,
groupBy: LinearGroupBy,
t: Translate = translate
): {
key: string
label: string
} {
if (groupBy === 'status') {
return {
key: `status:${issue.state.name}`,
label: issue.state.name
}
}
if (groupBy === 'assignee') {
return {
key: `assignee:${issue.assignee?.id ?? 'unassigned'}`,
label:
issue.assignee?.displayName ?? t('auto.components.TaskPage.groupUnassigned', 'Unassigned')
}
}
if (groupBy === 'priority') {
return {
key: `priority:${issue.priority}`,
label: getLinearPriorityLabel(issue.priority)
}
}
if (groupBy === 'team') {
return {
key: `team:${issue.team.id}`,
label: issue.team.name
}
}
return {
key: 'all',
label: t('auto.components.TaskPage.dfc0c79bd8', 'Issues')
}
}
export function groupLinearIssues(
issues: LinearIssue[],
groupBy: LinearGroupBy,
orderBy: LinearOrderBy,
t: Translate = translate
): LinearGroupSection[] {
const sorted = [...issues].sort((a, b) => compareLinearIssues(a, b, orderBy))
if (groupBy === 'none') {
return [
{
key: 'all',
label: t('auto.components.TaskPage.dfc0c79bd8', 'Issues'),
issues: sorted
}
]
}
const sections = new Map<string, LinearGroupSection>()
for (const issue of sorted) {
const group = getLinearIssueGroup(issue, groupBy, t)
const section = sections.get(group.key)
if (section) {
section.issues.push(issue)
} else {
sections.set(group.key, {
key: group.key,
label: group.label,
issues: [issue]
})
}
}
return [...sections.values()]
}
@@ -2,12 +2,12 @@ import {
isEmptyLinearIssueAttributeFilter,
linearIssueAttributeFilterSignature,
type LinearIssueAttributeFilter
} from '../../../../../shared/linear/issue-attribute-filter'
} from '../../../../../../shared/linear/issue-attribute-filter'
import {
getTaskSourceCacheScope,
type TaskSourceContext
} from '../../../../../shared/work-items/task-source-context'
import type { LinearIssueListReadArgs } from '../../../store/slices/linear/linear-slice-contract'
} from '../../../../../../shared/work-items/task-source-context'
import type { LinearIssueListReadArgs } from '../../../../store/slices/linear/linear-slice-contract'
export function isLinearIssueSearchActive(immediateQuery: string, appliedQuery: string): boolean {
return immediateQuery.trim().length > 0 || appliedQuery.trim().length > 0
@@ -1,84 +1,24 @@
import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope'
import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write'
import { taskProviderErrorMessage } from '@/lib/work-items/task-provider-error-copy'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type { LinearCustomViewModel } from '../../../../../shared/linear/project-types'
import type {
LinearCollectionResult,
LinearWorkflowState
} from '../../../../../shared/linear/workspace-types'
import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context'
import { getLinearSourceScope } from '../../../../store/slices/linear/linear-slice-scope'
import { writeLinearIssue } from '../../../../lib/work-items/linear/linear-issue-write'
import { taskProviderErrorMessage } from '../../../../lib/work-items/task-provider-error-copy'
import type { LinearIssue } from '../../../../../../shared/linear/issue-types'
import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context'
import React, { useState, useRef, useCallback } from 'react'
import { useAppStore } from '@/store/index'
import { useTeamStates } from '@/hooks/useIssueMetadata'
import { linearUpdateIssue } from '@/runtime/linear/runtime-linear-issue-client'
import { useAppStore } from '../../../../store/index'
import { useTeamStates } from '../../../../hooks/useIssueMetadata'
import { linearUpdateIssue } from '../../../../runtime/linear/runtime-linear-issue-client'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/class-names'
import { translate } from '../../../../i18n/i18n'
import { useTranslate } from '../../../../i18n/use-translate'
import { Popover, PopoverTrigger, PopoverContent } from '../../../ui/popover'
import { cn } from '../../../../lib/class-names'
import {
getLinearStatePillStyle,
getLinearStateMarkerStyle
} from '@/components/linear-state-pill-style'
} from '../../../linear-state-pill-style'
import { LoaderCircle, ChevronDown } from 'lucide-react'
import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host'
export type LinearProjectTab = 'overview' | 'issues'
export type LinearGroupSection = {
key: string
label: string
issues: LinearIssue[]
}
export type LinearIssueListRow =
| {
type: 'section'
key: string
label: string
count: number
}
| {
type: 'issue'
issue: LinearIssue
}
export const LINEAR_CUSTOM_VIEW_MODELS = [
'issue',
'project'
] satisfies readonly LinearCustomViewModel[]
export function mergeLinearCollectionResults<T>(
results: LinearCollectionResult<T>[]
): LinearCollectionResult<T> {
const errors = results.flatMap((result) => result.errors ?? [])
return {
items: results.flatMap((result) => result.items),
...(errors.length > 0
? {
errors
}
: {}),
...(results.some((result) => result.hasMore)
? {
hasMore: true
}
: {})
}
}
export function getLinearStatusSectionState(
section: LinearGroupSection
): LinearIssue['state'] | null {
if (!section.key.startsWith('status:')) {
return null
}
return section.issues[0]?.state ?? null
}
export function findLinearWorkflowStateForStatus(
states: LinearWorkflowState[],
targetState: LinearIssue['state']
): LinearWorkflowState | undefined {
return (
states.find((state) => state.name === targetState.name && state.type === targetState.type) ??
states.find((state) => state.name === targetState.name)
)
}
import { defaultScopeSource } from '../../../../runtime/hosts/selection/default-creation-host'
export function LinearStateCell({
issue,
className,
@@ -1,4 +1,4 @@
import type { LinearTeam } from '../../../../../shared/linear/workspace-types'
import type { LinearTeam } from '../../../../../../shared/linear/workspace-types'
// Treat malformed persisted selections as sticky-all; unvalidated strings previously crashed array filtering.
export function storedLinearTeamSelection(value: unknown): string[] | null {

Some files were not shown because too many files have changed in this diff Show More