diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 84559a65d66..491e47fa748 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -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 diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index a52ba10321a..62b0b0c36d6 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -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 diff --git a/config/react-compiler-uncompiled-files.txt b/config/react-compiler-uncompiled-files.txt index a78f7b1b07a..c1570bdeeab 100644 --- a/config/react-compiler-uncompiled-files.txt +++ b/config/react-compiler-uncompiled-files.txt @@ -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 diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 081ff049122..0618b09dd67 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -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, diff --git a/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs b/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs index 00753773695..2052fc083a1 100644 --- a/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs +++ b/config/scripts/preflight/changed-checks/check-changed-code-quality.mjs @@ -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' ]) ], [ diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 95529130196..e8b9f6350b4 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -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", diff --git a/config/tsconfig.test.pane-launch.json b/config/tsconfig.test.pane-launch.json index cdffd47c2df..cff73d0a79e 100644 --- a/config/tsconfig.test.pane-launch.json +++ b/config/tsconfig.test.pane-launch.json @@ -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", diff --git a/config/tsconfig.web.json b/config/tsconfig.web.json index b8af840abf1..d34ab3969b3 100644 --- a/config/tsconfig.web.json +++ b/config/tsconfig.web.json @@ -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": { diff --git a/package.json b/package.json index cf4dd2f8e49..c022ea14cf1 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/renderer/src/components/__mocks__/task-page-jira-favourite-fixture.ts b/src/renderer/src/components/__mocks__/task-page-jira-favourite-fixture.ts deleted file mode 100644 index b724951f850..00000000000 --- a/src/renderer/src/components/__mocks__/task-page-jira-favourite-fixture.ts +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/jira-key-or-text-search.ts b/src/renderer/src/components/jira-key-or-text-search.ts index 9d5950ed62d..df6e586bad1 100644 --- a/src/renderer/src/components/jira-key-or-text-search.ts +++ b/src/renderer/src/components/jira-key-or-text-search.ts @@ -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, diff --git a/src/renderer/src/components/task-page/Surface.tsx b/src/renderer/src/components/task-page/Surface.tsx index 736001ade30..2e2f2e28cd6 100644 --- a/src/renderer/src/components/task-page/Surface.tsx +++ b/src/renderer/src/components/task-page/Surface.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/TaskPage.tsx b/src/renderer/src/components/task-page/TaskPage.tsx index c8d42e8d023..8f37666a72d 100644 --- a/src/renderer/src/components/task-page/TaskPage.tsx +++ b/src/renderer/src/components/task-page/TaskPage.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx b/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx index a08d0c80fac..715877eb6ae 100644 --- a/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx +++ b/src/renderer/src/components/task-page/github/issue-status/StatusCell.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx b/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx index a573bc3441b..01b9751b7a5 100644 --- a/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx +++ b/src/renderer/src/components/task-page/github/listing/AssigneesCell.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts index 60c4063fb2e..ff26f96b099 100644 --- a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-pending.ts @@ -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< diff --git a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts index a45c6318b84..d0760ce4678 100644 --- a/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts +++ b/src/renderer/src/components/task-page/github/listing/use-task-page-github-list-projection.ts @@ -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' diff --git a/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts index e09ec3e4cb8..0fd7f0622c4 100644 --- a/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts +++ b/src/renderer/src/components/task-page/github/mutations/task-page-github-work-item-mutation-types.ts @@ -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 +} diff --git a/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts b/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts index cd0a1f6e7a8..7a7d10bfa68 100644 --- a/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts +++ b/src/renderer/src/components/task-page/github/mutations/use-task-page-github-mutation-state.ts @@ -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' diff --git a/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx b/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx index 66d3830ecff..2c66b87a296 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/MergeCell.tsx @@ -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({ diff --git a/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx b/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx index 175ce368376..3798f9603e1 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx +++ b/src/renderer/src/components/task-page/github/pull-requests/ReviewCell.tsx @@ -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 diff --git a/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts index 46bf6f0376c..cf1f380fb8e 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts +++ b/src/renderer/src/components/task-page/github/pull-requests/task-page-github-reviewer-actions.ts @@ -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 = { diff --git a/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts b/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts index ce313a40bb7..e38a5fc6f82 100644 --- a/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts +++ b/src/renderer/src/components/task-page/github/pull-requests/use-task-page-pr-check-hydration.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/Content.tsx b/src/renderer/src/components/task-page/jira/Content.tsx index 4a7ca204a0c..3600962a014 100644 --- a/src/renderer/src/components/task-page/jira/Content.tsx +++ b/src/renderer/src/components/task-page/jira/Content.tsx @@ -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 { diff --git a/src/renderer/src/components/task-page/jira/Filters.tsx b/src/renderer/src/components/task-page/jira/Filters.tsx index 94087bc020e..5d999ef3812 100644 --- a/src/renderer/src/components/task-page/jira/Filters.tsx +++ b/src/renderer/src/components/task-page/jira/Filters.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx b/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx deleted file mode 100644 index 55a723ed074..00000000000 --- a/src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx +++ /dev/null @@ -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['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() - 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( - - ) - 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( - - ) - fireEvent.click(screen.getByRole('button', { name: 'Assign to me (Remote me)' })) - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ accountId: 'remote-me' })) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-move-dialog.tsx b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move-dialog.tsx similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-move-dialog.tsx rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move-dialog.tsx index 682e1babaf4..b849151157a 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-move-dialog.tsx +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move-dialog.tsx @@ -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 = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-move.ts b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move.ts similarity index 89% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-move.ts rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move.ts index 5bd213a77c9..36569d2c881 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-move.ts +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-move.ts @@ -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 diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-sections.ts b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-sections.ts similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-sections.ts rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-sections.ts index bdc8520dfcf..89d4d026aa0 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-sections.ts +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-sections.ts @@ -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 diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.ts b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-transitions.ts similarity index 92% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.ts rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-transitions.ts index 08978ea9e58..645c56e9d86 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.ts +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-transitions.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx similarity index 94% rename from src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx index fbe6b20685e..6ea2209f254 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-view.tsx +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board-view.tsx @@ -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[] diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board.tsx b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board.tsx similarity index 99% rename from src/renderer/src/components/task-page/jira/task-page-jira-board.tsx rename to src/renderer/src/components/task-page/jira/boards/task-page-jira-board.tsx index a2e6183a195..f86846a7024 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board.tsx +++ b/src/renderer/src/components/task-page/jira/boards/task-page-jira-board.tsx @@ -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 diff --git a/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx b/src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx similarity index 96% rename from src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx index 051628eab53..1d81be293df 100644 --- a/src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueAssigneeField.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/jira/IssueDialog.tsx b/src/renderer/src/components/task-page/jira/creation/IssueDialog.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/IssueDialog.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueDialog.tsx index b2d8aa673b2..aa7ad3ab009 100644 --- a/src/renderer/src/components/task-page/jira/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueDialog.tsx @@ -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({ diff --git a/src/renderer/src/components/task-page/jira/IssueTitleField.tsx b/src/renderer/src/components/task-page/jira/creation/IssueTitleField.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/IssueTitleField.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueTitleField.tsx index e5c99a559c8..1f859e72c24 100644 --- a/src/renderer/src/components/task-page/jira/IssueTitleField.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueTitleField.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/jira/IssueUserField.tsx b/src/renderer/src/components/task-page/jira/creation/IssueUserField.tsx similarity index 92% rename from src/renderer/src/components/task-page/jira/IssueUserField.tsx rename to src/renderer/src/components/task-page/jira/creation/IssueUserField.tsx index 4243aa53873..aaf545134c1 100644 --- a/src/renderer/src/components/task-page/jira/IssueUserField.tsx +++ b/src/renderer/src/components/task-page/jira/creation/IssueUserField.tsx @@ -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, diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-create-fields.ts b/src/renderer/src/components/task-page/jira/creation/task-page-jira-create-fields.ts similarity index 98% rename from src/renderer/src/components/task-page/jira/task-page-jira-create-fields.ts rename to src/renderer/src/components/task-page/jira/creation/task-page-jira-create-fields.ts index 75728349eb3..eac52fd8e92 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-create-fields.ts +++ b/src/renderer/src/components/task-page/jira/creation/task-page-jira-create-fields.ts @@ -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([ diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-metadata.ts similarity index 100% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-metadata.ts diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts similarity index 98% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts index 4ef8cb4afd8..6672fd53548 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-projects.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-projects.ts @@ -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 diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts similarity index 97% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts index f7075dc9606..9b01f898f0c 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-creation-state.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-creation-state.ts @@ -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 { diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts index f7ee09e20de..6739ab0c53d 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-issue-creation.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-issue-creation.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx index 8a78a86819a..f516032f699 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.test.tsx @@ -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() })) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts rename to src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts index 6193651324f..0a0efcecde8 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.ts +++ b/src/renderer/src/components/task-page/jira/creation/use-task-page-jira-summary-generation.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraFavouriteFilters.tsx b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraFavouriteFilters.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/TaskPageJiraFavouriteFilters.tsx rename to src/renderer/src/components/task-page/jira/filters/TaskPageJiraFavouriteFilters.tsx index fd6f6f70b7f..6001a5cde4c 100644 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraFavouriteFilters.tsx +++ b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraFavouriteFilters.tsx @@ -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 diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.tsx b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraSavedFilters.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.tsx rename to src/renderer/src/components/task-page/jira/filters/TaskPageJiraSavedFilters.tsx index 93ec324453f..1516976151a 100644 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.tsx +++ b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraSavedFilters.tsx @@ -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, diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.tsx b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraStatusFilters.tsx similarity index 94% rename from src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.tsx rename to src/renderer/src/components/task-page/jira/filters/TaskPageJiraStatusFilters.tsx index 90901e76c61..32342f7ba56 100644 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.tsx +++ b/src/renderer/src/components/task-page/jira/filters/TaskPageJiraStatusFilters.tsx @@ -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 diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-status-catalog.ts b/src/renderer/src/components/task-page/jira/filters/task-page-jira-status-catalog.ts similarity index 87% rename from src/renderer/src/components/task-page/jira/task-page-jira-status-catalog.ts rename to src/renderer/src/components/task-page/jira/filters/task-page-jira-status-catalog.ts index 79b9eeb958f..3d1b6b9e113 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-status-catalog.ts +++ b/src/renderer/src/components/task-page/jira/filters/task-page-jira-status-catalog.ts @@ -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, diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts index bd5ebab979c..24b1f3618ea 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-favourite-filters.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-favourite-filters.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx index ad095a2911e..30f03b92a4e 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.test.tsx @@ -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( diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.ts similarity index 79% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.ts index 4f562a9d885..152fb3156a0 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-project-scope.ts @@ -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[], diff --git a/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx new file mode 100644 index 00000000000..22a609c5a82 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.test.tsx @@ -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[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 = 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']) +}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts index 643fc68755e..20975f4b884 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-quick-filters.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx new file mode 100644 index 00000000000..9d536b63eb9 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.test.tsx @@ -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[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) +}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts similarity index 96% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts rename to src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts index 33a251c9baa..249b7f66ec7 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.ts +++ b/src/renderer/src/components/task-page/jira/filters/use-task-page-jira-saved-filters.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-cache-selectors.ts similarity index 91% rename from src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-cache-selectors.ts index 3cd7c652cf2..1a82b394c33 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-cache-selectors.ts @@ -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> type JiraSearchCache = Record> diff --git a/src/renderer/src/components/task-page/jira/listing/task-page-jira-error-banner.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-error-banner.tsx new file mode 100644 index 00000000000..66c7e3348d0 --- /dev/null +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-error-banner.tsx @@ -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 ( + +
+ +
+
{error.title}
+ {error.details ? ( + <> + + + + +
+ {error.details} +
+
+ + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-issue-cells.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-cells.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/task-page-jira-issue-cells.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-cells.tsx index 62d2d04066a..9aa443b1006 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-issue-cells.tsx +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-cells.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-issue-list.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-list.tsx similarity index 97% rename from src/renderer/src/components/task-page/jira/task-page-jira-issue-list.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-list.tsx index c54a848b1dc..ff2f91ea43b 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-issue-list.tsx +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-issue-list.tsx @@ -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 = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-linked-issues.ts similarity index 89% rename from src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-linked-issues.ts index f7dd8ccabf2..7a1b78d8815 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-linked-issues.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-load-state.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-load-state.ts similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-load-state.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-load-state.ts index e5940978fdb..7fe760f476e 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-load-state.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-load-state.ts @@ -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 = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-search.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-search.ts similarity index 87% rename from src/renderer/src/components/task-page/jira/task-page-jira-search.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-search.ts index 80e3669481c..34b7d68fbe4 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-search.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-search.ts @@ -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 = { diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-sort-controls.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-sort-controls.tsx similarity index 98% rename from src/renderer/src/components/task-page/jira/task-page-jira-sort-controls.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-sort-controls.tsx index bbd2312c42f..57babdf9f10 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-sort-controls.tsx +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-sort-controls.tsx @@ -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[] diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts similarity index 95% rename from src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts index adef776958e..48d3bfabb27 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-status-order.ts +++ b/src/renderer/src/components/task-page/jira/listing/task-page-jira-status-order.ts @@ -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' diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-status-tone.ts b/src/renderer/src/components/task-page/jira/listing/task-page-jira-status-tone.ts similarity index 100% rename from src/renderer/src/components/task-page/jira/task-page-jira-status-tone.ts rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-status-tone.ts diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.tsx b/src/renderer/src/components/task-page/jira/listing/task-page-jira-text-fallback-notice.tsx similarity index 100% rename from src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.tsx rename to src/renderer/src/components/task-page/jira/listing/task-page-jira-text-fallback-notice.tsx diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts similarity index 92% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts rename to src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts index 011c0d589a9..f51132496cc 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.ts +++ b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-effects.ts @@ -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, diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts similarity index 93% rename from src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts rename to src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts index 5e5afd56182..82ff87c1ec0 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-projection.ts +++ b/src/renderer/src/components/task-page/jira/listing/use-task-page-jira-list-projection.ts @@ -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, diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts b/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts deleted file mode 100644 index 54a512bc239..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts +++ /dev/null @@ -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 - ) - }) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx deleted file mode 100644 index 364a38f2b47..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx +++ /dev/null @@ -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 }) { - 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() - 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() - 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') - } -) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts b/src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts deleted file mode 100644 index 4aa765c9cb8..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx deleted file mode 100644 index f74d5515a43..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx +++ /dev/null @@ -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( - - 'Today'} - getStatusTone={() => ''} - onOpenIssue={onOpenIssue} - onStartWorkspace={vi.fn()} - /> - - ) -} -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() - }) -}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts b/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts index c41c110238e..c251c470fca 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts +++ b/src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.ts @@ -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) { diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx deleted file mode 100644 index cdcdb449843..00000000000 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx +++ /dev/null @@ -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[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 = 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) - } -) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx deleted file mode 100644 index 8853cfba223..00000000000 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx +++ /dev/null @@ -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[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 }) => {children} - } - ) - 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) -}) diff --git a/src/renderer/src/components/task-page/linear/Content.tsx b/src/renderer/src/components/task-page/linear/Content.tsx index a50450d9d5c..f4a8b506b3c 100644 --- a/src/renderer/src/components/task-page/linear/Content.tsx +++ b/src/renderer/src/components/task-page/linear/Content.tsx @@ -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 }: { diff --git a/src/renderer/src/components/task-page/linear/IssueRows.tsx b/src/renderer/src/components/task-page/linear/IssueRows.tsx deleted file mode 100644 index ad47fc1d0c1..00000000000 --- a/src/renderer/src/components/task-page/linear/IssueRows.tsx +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/task-page/linear/IssueTable.test.tsx b/src/renderer/src/components/task-page/linear/IssueTable.test.tsx deleted file mode 100644 index 806f7f723fa..00000000000 --- a/src/renderer/src/components/task-page/linear/IssueTable.test.tsx +++ /dev/null @@ -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 ( - - - - ) -} - -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() -}) diff --git a/src/renderer/src/components/task-page/linear/IssueAttributes.tsx b/src/renderer/src/components/task-page/linear/creation/IssueAttributes.tsx similarity index 99% rename from src/renderer/src/components/task-page/linear/IssueAttributes.tsx rename to src/renderer/src/components/task-page/linear/creation/IssueAttributes.tsx index bcceb1610e3..e359edb4f01 100644 --- a/src/renderer/src/components/task-page/linear/IssueAttributes.tsx +++ b/src/renderer/src/components/task-page/linear/creation/IssueAttributes.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/linear/IssueDialog.tsx b/src/renderer/src/components/task-page/linear/creation/IssueDialog.tsx similarity index 98% rename from src/renderer/src/components/task-page/linear/IssueDialog.tsx rename to src/renderer/src/components/task-page/linear/creation/IssueDialog.tsx index 02da2829b72..638d9219427 100644 --- a/src/renderer/src/components/task-page/linear/IssueDialog.tsx +++ b/src/renderer/src/components/task-page/linear/creation/IssueDialog.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/linear/ProjectDialog.tsx b/src/renderer/src/components/task-page/linear/creation/ProjectDialog.tsx similarity index 98% rename from src/renderer/src/components/task-page/linear/ProjectDialog.tsx rename to src/renderer/src/components/task-page/linear/creation/ProjectDialog.tsx index bed683d4d8b..027429d9668 100644 --- a/src/renderer/src/components/task-page/linear/ProjectDialog.tsx +++ b/src/renderer/src/components/task-page/linear/creation/ProjectDialog.tsx @@ -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, diff --git a/src/renderer/src/components/task-page/linear/ProjectFields.tsx b/src/renderer/src/components/task-page/linear/creation/ProjectFields.tsx similarity index 99% rename from src/renderer/src/components/task-page/linear/ProjectFields.tsx rename to src/renderer/src/components/task-page/linear/creation/ProjectFields.tsx index 95098699104..1304a4ad433 100644 --- a/src/renderer/src/components/task-page/linear/ProjectFields.tsx +++ b/src/renderer/src/components/task-page/linear/creation/ProjectFields.tsx @@ -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' diff --git a/src/renderer/src/components/task-page/linear/linear-attribute-count-labels.ts b/src/renderer/src/components/task-page/linear/creation/linear-attribute-count-labels.ts similarity index 100% rename from src/renderer/src/components/task-page/linear/linear-attribute-count-labels.ts rename to src/renderer/src/components/task-page/linear/creation/linear-attribute-count-labels.ts diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts similarity index 98% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts rename to src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts index 8780dbe2cf9..24882cf576d 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts +++ b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-creation-state.ts @@ -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( diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts similarity index 99% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts rename to src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts index b803039c4fd..296540264b8 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts +++ b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-issue-creation.ts @@ -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) { diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts similarity index 97% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts rename to src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts index d3fe6b4509b..e988d5f0395 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts +++ b/src/renderer/src/components/task-page/linear/creation/use-task-page-linear-project-creation.ts @@ -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' diff --git a/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx new file mode 100644 index 00000000000..a93870f5df4 --- /dev/null +++ b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-lifecycle.test.tsx @@ -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() +}) diff --git a/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures.tsx b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures.tsx new file mode 100644 index 00000000000..10f3d9ce9ea --- /dev/null +++ b/src/renderer/src/components/task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures.tsx @@ -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() + return { + ...actual, + useReducer(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) => unknown) => + useStore(active.store(), selector), + { + getState: () => active.store().getState(), + subscribe: (...args: Parameters) => active.store().subscribe(...args) + } + ) +})) + +vi.mock('@/i18n/i18n', async (original) => ({ + ...(await original>()), + translate: (_key: string, fallback: string, values?: Record) => + 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>()), + 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>()), + 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>()), + linearGetIssue: reads.detail, + linearIssueComments: async () => [], + linearUpdateIssue: reads.update, + linearCreateIssue: reads.create +})) + +vi.mock('@/hooks/useIssueMetadata', async (original) => ({ + ...(await original>()), + 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>()), useAllWorktrees: () => empty } +}) + +vi.mock('@/store/workspaces/catalog/workspace-record-fields', async (original) => { + const empty: [] = [] + return { ...(await original>()), 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 ? ( + + ) : ( +
+ ) +})) + +export function DrawerFields({ row, editState }: { row: LinearIssue; editState: LinearEditState }) { + const controller = useLinearIssueEditController({ issue: row, editState }) + return ( + <> + + {row.state.name}:{editState.priority}:{editState.labels.join(',')} + + + + + + ) +} + +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 + +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 } diff --git a/src/renderer/src/components/task-page/linear/IssueBoard.tsx b/src/renderer/src/components/task-page/linear/issues/IssueBoard.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/IssueBoard.tsx rename to src/renderer/src/components/task-page/linear/issues/IssueBoard.tsx index fd58793e71e..00ec7c55584 100644 --- a/src/renderer/src/components/task-page/linear/IssueBoard.tsx +++ b/src/renderer/src/components/task-page/linear/issues/IssueBoard.tsx @@ -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({ diff --git a/src/renderer/src/components/task-page/linear/IssueBoardColumns.tsx b/src/renderer/src/components/task-page/linear/issues/IssueBoardColumns.tsx similarity index 98% rename from src/renderer/src/components/task-page/linear/IssueBoardColumns.tsx rename to src/renderer/src/components/task-page/linear/issues/IssueBoardColumns.tsx index bed498c9813..981dfd2759f 100644 --- a/src/renderer/src/components/task-page/linear/IssueBoardColumns.tsx +++ b/src/renderer/src/components/task-page/linear/issues/IssueBoardColumns.tsx @@ -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 diff --git a/src/renderer/src/components/task-page/linear/IssueList.tsx b/src/renderer/src/components/task-page/linear/issues/IssueList.tsx similarity index 95% rename from src/renderer/src/components/task-page/linear/IssueList.tsx rename to src/renderer/src/components/task-page/linear/issues/IssueList.tsx index 7a06ebc962c..0da6ca97b20 100644 --- a/src/renderer/src/components/task-page/linear/IssueList.tsx +++ b/src/renderer/src/components/task-page/linear/issues/IssueList.tsx @@ -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({
) : null} - + {selectedLinearProject && linearProjectTab === 'issues' ? ( <> compareLinearIssues(a, b, orderBy)) + if (groupBy === 'none') { + return [ + { + key: 'all', + label: t('auto.components.TaskPage.dfc0c79bd8', 'Issues'), + issues: sorted + } + ] + } + const sections = new Map() + 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()] +} diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-issue-request.ts b/src/renderer/src/components/task-page/linear/issues/task-page-linear-issue-request.ts similarity index 92% rename from src/renderer/src/components/task-page/linear/task-page-linear-issue-request.ts rename to src/renderer/src/components/task-page/linear/issues/task-page-linear-issue-request.ts index e94284bb17b..beccb32df3d 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-issue-request.ts +++ b/src/renderer/src/components/task-page/linear/issues/task-page-linear-issue-request.ts @@ -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 diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx b/src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx similarity index 69% rename from src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx rename to src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx index b309a04b8b6..a1a05c7e9a7 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx +++ b/src/renderer/src/components/task-page/linear/issues/task-page-linear-state-cell.tsx @@ -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( - results: LinearCollectionResult[] -): LinearCollectionResult { - 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, diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page/linear/issues/task-page-linear-team-selection.ts similarity index 91% rename from src/renderer/src/components/task-page/linear/task-page-linear-team-selection.ts rename to src/renderer/src/components/task-page/linear/issues/task-page-linear-team-selection.ts index 27c4b96cc68..93bf864faa4 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-team-selection.ts +++ b/src/renderer/src/components/task-page/linear/issues/task-page-linear-team-selection.ts @@ -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 { diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts similarity index 88% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts index a5abaaf64be..9eeae5753ff 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-board.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-board.ts @@ -3,7 +3,7 @@ import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' import { taskProviderErrorMessage } from '@/lib/work-items/task-provider-error-copy' import type { TaskPageLinearListProjectionModel } from './use-task-page-linear-list-projection' import React, { useMemo, useCallback } from 'react' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' import { writeLinearBoardIssueDragData, readLinearBoardIssueDragData @@ -12,15 +12,14 @@ import { linearTeamStates } from '@/runtime/linear/runtime-linear-project-client import { linearUpdateIssue } from '@/runtime/linear/runtime-linear-issue-client' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import type { LinearDisplayProperty } from '../../../../../shared/linear/issue-view-resume-state' -import { groupLinearIssues } from './task-page-linear-jira-list-model' -import type { LinearGroupSection } from './task-page-linear-issue-model' -import { - findLinearWorkflowStateForStatus, - getLinearStatusSectionState -} from './task-page-linear-issue-model' +import { useAppStore } from '@/store/index' +import type { LinearDisplayProperty } from '../../../../../../shared/linear/issue-view-resume-state' +import { groupLinearIssues } from './task-page-linear-issue-grouping' +import type { LinearGroupSection } from './task-page-linear-issue-grouping' + import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' +import type { LinearWorkflowState } from '../../../../../../shared/linear/workspace-types' + export function useTaskPageLinearBoard(model: TaskPageLinearListProjectionModel) { const { settings, @@ -209,3 +208,22 @@ export function useTaskPageLinearBoard(model: TaskPageLinearListProjectionModel) return nextModel } export type TaskPageLinearBoardModel = ReturnType + +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) + ) +} diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts similarity index 92% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts index c2a925b3d20..7f470d59440 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-filter-selection.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-filter-selection.ts @@ -4,15 +4,15 @@ import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linea import { type LinearIssueAttributeFilter, linearIssueAttributeFilterSignature -} from '../../../../../shared/linear/issue-attribute-filter' -import { setLinearWorkspaceIssueFilter } from '../../../../../shared/linear/issue-view-resume-state' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' +} from '../../../../../../shared/linear/issue-attribute-filter' +import { setLinearWorkspaceIssueFilter } from '../../../../../../shared/linear/issue-view-resume-state' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' import { type LinearPrimaryTeamObservation, shouldClearTeamDerivedFacets, teamDerivedFacetsForPrimaryTeamChange, isLinearIssueSearchActive -} from '@/components/task-page/linear/task-page-linear-issue-request' +} from '@/components/task-page/linear/issues/task-page-linear-issue-request' export function useTaskPageLinearFilterSelection(model: TaskPageLinearListSelectionPreludeModel) { const { linearMode, diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-in-orca-effects.ts similarity index 96% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-in-orca-effects.ts index b2d6c6543cd..70cfdc4c0d2 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-in-orca-effects.ts @@ -4,8 +4,8 @@ import { useEffect } from 'react' import { readLinkedLinearIssuesWithLimit, filterLinearIssuesForInOrcaWorkspace -} from '@/components/task-page/linear/task-page-linear-in-orca-issues' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' +} from '@/components/task-page/linear/issues/task-page-linear-in-orca-issues' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' import { translate } from '@/i18n/i18n' function isCurrentLinearRequest( diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts similarity index 92% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts index f7365e19ce4..d83f0060065 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-effects.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-effects.ts @@ -2,29 +2,29 @@ import { observeLinearIssueRead, type LinearIssueRead } from '@/store/slices/linear/linear-issue-read-lifecycle' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import type { LinearCollectionResult } from '../../../../../shared/linear/workspace-types' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' +import type { LinearCollectionResult } from '../../../../../../shared/linear/workspace-types' import { taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' -import type { TaskPageGlobalEffectsModel } from '../use-task-page-global-effects' +import type { TaskPageGlobalEffectsModel } from '../../use-task-page-global-effects' import { useEffect } from 'react' -import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' -import { saveLinearIssueView } from '../../linear-issue-view-storage' -import { serializeLinearIssueViewResumeState } from '../../../../../shared/linear/issue-view-resume-state' -import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../../shared/integration-credential-errors' +import { saveLinearIssueView } from '../../../linear-issue-view-storage' +import { serializeLinearIssueViewResumeState } from '../../../../../../shared/linear/issue-view-resume-state' +import { clampLinearIssueListLimit } from '../../../../../../shared/linear/issue-read-limits' import { buildLinearIssueListReadArgs, type LinearIssueListFilterRead, shouldForceLinearIssueListRead, buildLinearIssueListRequestSignature -} from '@/components/task-page/linear/task-page-linear-issue-request' -import { linearIssueAttributeFilterSignature } from '../../../../../shared/linear/issue-attribute-filter' +} from '@/components/task-page/linear/issues/task-page-linear-issue-request' +import { linearIssueAttributeFilterSignature } from '../../../../../../shared/linear/issue-attribute-filter' import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' import { taskPageLinearIssuesHaveMore, reconcileTaskPageLinearIssuesAfterLandingRefresh } from './task-page-linear-cache-selectors' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' -import { useTaskSearchDebouncedApply } from '../use-task-search-debounced-apply' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' +import { useTaskSearchDebouncedApply } from '../../use-task-search-debounced-apply' export function useTaskPageLinearListEffects(model: TaskPageGlobalEffectsModel) { const { setTaskResumeState, diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts similarity index 76% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts index 3dc1fa5aaef..2661f3f355c 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-presentation.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-presentation.ts @@ -1,8 +1,10 @@ import React, { useMemo } from 'react' -import { getLinearIssueGridTemplate, groupLinearIssues } from './task-page-linear-jira-list-model' -import type { LinearIssueListRow } from './task-page-linear-issue-model' +import { groupLinearIssues } from './task-page-linear-issue-grouping' + import type { TaskPageLinearListProjectionPreludeModel } from './use-task-page-linear-list-projection' +import type { LinearIssue } from '../../../../../../shared/linear/issue-types' +import type { LinearDisplayProperty } from '../../../../../../shared/linear/issue-view-resume-state' export function useTaskPageLinearListPresentation(model: TaskPageLinearListProjectionPreludeModel) { const { @@ -98,3 +100,39 @@ export function useTaskPageLinearListPresentation(model: TaskPageLinearListProje export type TaskPageLinearListPresentationModel = ReturnType< typeof useTaskPageLinearListPresentation > + +export type LinearIssueListRow = + | { + type: 'section' + key: string + label: string + count: number + } + | { + type: 'issue' + issue: LinearIssue + } + +export function getLinearIssueGridTemplate( + visibleProperties: ReadonlySet +): string { + const columns = ['96px', 'minmax(240px,1.55fr)'] + if (visibleProperties.has('labels')) { + columns.push('minmax(168px,0.9fr)') + } + if (visibleProperties.has('team')) { + columns.push('minmax(172px,0.9fr)') + } + if (visibleProperties.has('state')) { + columns.push('138px') + } + if (visibleProperties.has('assignee')) { + columns.push('64px') + } + if (visibleProperties.has('updated')) { + columns.push('104px') + } + // Why: Worktrees is icon-only (open vs start); keep it narrow so issue title keeps the room. + columns.push('64px') + return columns.join(' ') +} diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts similarity index 97% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts index 26bafb6a469..20f35e45b3f 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-projection.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-projection.ts @@ -1,12 +1,12 @@ import type { TaskPageLinearListSelectionModel } from './use-task-page-linear-list-selection' import { useMemo, useCallback, useEffect } from 'react' -import { filterLinearIssuesBySearchQuery } from '@/components/task-page/linear/task-page-linear-in-orca-issues' +import { filterLinearIssuesBySearchQuery } from '@/components/task-page/linear/issues/task-page-linear-in-orca-issues' import { clampLinearIssueListLimit, LINEAR_ISSUE_LIST_MAX -} from '../../../../../shared/linear/issue-read-limits' -import { compareLinearIssues } from './task-page-linear-jira-list-model' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' +} from '../../../../../../shared/linear/issue-read-limits' +import { compareLinearIssues } from './task-page-linear-issue-grouping' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' import { useTaskPageLinearListPresentation } from './use-task-page-linear-list-presentation' export function useTaskPageLinearListProjectionPrelude(model: TaskPageLinearListSelectionModel) { const { diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts similarity index 96% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts rename to src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts index 13a01d43e64..6eeb5765abc 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-list-selection.ts +++ b/src/renderer/src/components/task-page/linear/issues/use-task-page-linear-list-selection.ts @@ -1,17 +1,17 @@ import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' -import type { TaskPageProviderMetadataModel } from '../use-task-page-provider-metadata' +import type { TaskPageProviderMetadataModel } from '../../use-task-page-provider-metadata' import { useState, useMemo, useEffect } from 'react' -import { LINEAR_ISSUE_LIST_MAX } from '../../../../../shared/linear/issue-read-limits' +import { LINEAR_ISSUE_LIST_MAX } from '../../../../../../shared/linear/issue-read-limits' import { useTranslate } from '@/i18n/use-translate' -import type { LinearTeam } from '../../../../../shared/linear/workspace-types' +import type { LinearTeam } from '../../../../../../shared/linear/workspace-types' import { buildLinearTeamUrl, getLinearOrganizationUrlKeyFromIssueUrl -} from '../../../../../shared/linear/links' +} from '../../../../../../shared/linear/links' import { reconcileLinearTeamSelection, storedLinearTeamSelection -} from '@/components/task-page/linear/task-page-linear-team-selection' +} from '@/components/task-page/linear/issues/task-page-linear-team-selection' import { useTaskPageLinearFilterSelection } from './use-task-page-linear-filter-selection' export type TaskPageLinearListSelectionPreludeModel = ReturnType< typeof useTaskPageLinearListSelectionPrelude diff --git a/src/renderer/src/components/task-page/linear/ProjectList.tsx b/src/renderer/src/components/task-page/linear/projects/ProjectList.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/ProjectList.tsx rename to src/renderer/src/components/task-page/linear/projects/ProjectList.tsx index 4855a01b4b9..95f2bddbbab 100644 --- a/src/renderer/src/components/task-page/linear/ProjectList.tsx +++ b/src/renderer/src/components/task-page/linear/projects/ProjectList.tsx @@ -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 { LinearProjectTable, diff --git a/src/renderer/src/components/task-page/linear/ProjectOverview.tsx b/src/renderer/src/components/task-page/linear/projects/ProjectOverview.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/ProjectOverview.tsx rename to src/renderer/src/components/task-page/linear/projects/ProjectOverview.tsx index 3d453b0b727..d23ea8d120b 100644 --- a/src/renderer/src/components/task-page/linear/ProjectOverview.tsx +++ b/src/renderer/src/components/task-page/linear/projects/ProjectOverview.tsx @@ -1,4 +1,4 @@ -import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions' +import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' import { LinearProjectOverview } from '@/components/linear-project-view-surfaces' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' export function TaskPageLinearProjectOverview({ diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-collection-effects.ts b/src/renderer/src/components/task-page/linear/projects/use-task-page-linear-collection-effects.ts similarity index 93% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-collection-effects.ts rename to src/renderer/src/components/task-page/linear/projects/use-task-page-linear-collection-effects.ts index dcc217fa5f8..395c29f2f3e 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-collection-effects.ts +++ b/src/renderer/src/components/task-page/linear/projects/use-task-page-linear-collection-effects.ts @@ -2,12 +2,12 @@ import { observeLinearIssueRead } from '@/store/slices/linear/linear-issue-read- import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' import { taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' import { translate } from '@/i18n/i18n' -import type { TaskPageLinearInOrcaEffectsModel } from './use-task-page-linear-in-orca-effects' +import type { TaskPageLinearInOrcaEffectsModel } from '../issues/use-task-page-linear-in-orca-effects' import { useEffect } from 'react' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' -import { useTaskSearchDebouncedApply } from '../use-task-search-debounced-apply' -import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' -import { useTaskPageLinearCustomViewEffects } from './use-task-page-linear-custom-view-effects' +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' +import { useTaskSearchDebouncedApply } from '../../use-task-search-debounced-apply' +import { clampLinearIssueListLimit } from '../../../../../../shared/linear/issue-read-limits' +import { useTaskPageLinearCustomViewEffects } from '../views/use-task-page-linear-custom-view-effects' export function useTaskPageLinearCollectionEffectsPrelude(model: TaskPageLinearInOrcaEffectsModel) { const { diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx deleted file mode 100644 index 6ead6b39cee..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx +++ /dev/null @@ -1,361 +0,0 @@ -// @vitest-environment happy-dom -import { - reads, - task, - store, - source, - useRoutingFixture -} from './task-page-linear-refresh-test-fixtures' -import { reads as issueReadRegistry } from '@/store/slices/linear/linear-issue-read-lifecycle' -import '@/store/slices/linear/linear-issue-read' -import { pendingLinearIssueWrites } from '@/store/slices/linear/linear-issue-write-publication' -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -import { useLinearIssueWorkspaceDetail } from '../../linear-issue-workspace-detail-state' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import { expect, it } from 'vitest' -import { act, render, screen } from '@testing-library/react' -import { deferred } from '../../../store/slices/linear/linear-slice-test-harness' - -import { Suspense, startTransition, useState, useLayoutEffect } from 'react' -import { useLinearIssuePublication } from '@/hooks/useLinearIssuePublication' -import type { useTaskPageDetailRouting } from '../use-task-page-detail-routing' - -it.each([true, false])( - 'publication speculative B (suspend=%s) preserves committed authority', - async (suspend) => { - store() - const A = source('local'), - B = { ...A, accountLabel: 'B' } - const blocked = deferred() - const pending = deferred<{ ok: boolean }>() - const seen: string[] = [], - committed: string[] = [] - let navigate!: (value: boolean) => void - function Consumer({ b }: { b: boolean }) { - const row = useLinearIssuePublication(task(), b ? B : A).issue! - seen.push(b ? 'B' : 'A') - useLayoutEffect(() => { - committed.push(b ? 'B' : 'A') - }) - if (b && suspend) { - throw blocked.promise - } - return ( - - {b ? 'B' : 'A'}:{row.priority} - - ) - } - function Harness() { - const [b, setB] = useState(false) - navigate = setB - return ( - Blocked}> - - - ) - } - const mounted = render() - await act(async () => { - startTransition(() => navigate(true)) - }) - expect(seen).toContain('B') - expect(committed.includes('B')).toBe(!suspend) - expect(screen.getByTestId('publication-scope').textContent).toBe(suspend ? 'A:0' : 'B:0') - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue(task(), { priority: 4 }, A, () => pending.promise) - }) - if (suspend) { - await act(async () => { - navigate(false) - }) - } - const visible = screen.getByTestId('publication-scope').textContent - try { - expect(visible).toBe(suspend ? 'A:4' : 'B:0') - } finally { - mounted.unmount() - pending.resolve({ ok: false }) - await saving - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - } - } -) - -it.each([true, false])( - 'real routing updater discard (suspend=%s) cannot retire a pending field publication', - async (suspend) => { - const s = store() - s.setState({ jiraIssueCache: {}, jiraSearchCache: {} }) - const context = defaultScopeSource(null) - const pendingPriority = deferred<{ ok: boolean }>(), - pendingTitle = deferred<{ ok: boolean }>(), - pendingEarlierTitle = deferred<{ ok: boolean }>() - const hydrated = deferred(), - blocked = deferred() - reads.detail.mockReturnValue(hydrated.promise) - let route!: ReturnType - let pulse!: () => void - const renders: string[] = [], - commits: string[] = [] - let block = true - function Consumer() { - const [tick, setTick] = useState(0) - const current = useRoutingFixture(null) - useLayoutEffect(() => { - route = current - pulse = () => setTick((n) => n + 1) - commits.push( - `${current.selectedLinearIssue?.state.name}:${current.selectedLinearIssue?.priority}` - ) - }) - renders.push( - `${current.selectedLinearIssue?.state.name}:${current.selectedLinearIssue?.priority}` - ) - if (suspend && block && current.selectedLinearIssue?.state.name === 'Hydrated') { - throw blocked.promise - } - return ( - - {current.selectedLinearIssue?.title}:{current.selectedLinearIssue?.priority}:{tick} - - ) - } - const mounted = render( - Blocked}> - - - ) - act(() => route.setSelectedLinearIssue(task())) - let lease!: ReturnType['fetchLinearIssue']> extends { - acquire: () => infer L - } - ? L - : never - let prioritySaving!: Promise<{ ok: boolean }>, - titleSaving!: Promise<{ ok: boolean }>, - earlierTitleSaving!: Promise<{ ok: boolean }> - await act(async () => { - startTransition(() => { - earlierTitleSaving = writeLinearIssue( - task(), - { title: 'Earlier title' }, - context, - () => pendingEarlierTitle.promise - ) - prioritySaving = writeLinearIssue( - task(), - { priority: 4 }, - context, - () => pendingPriority.promise - ) - lease = s - .getState() - .fetchLinearIssue(task().id, 'w', { force: true, seed: task() }) - .acquire() - }) - hydrated.resolve(task('Hydrated')) - await hydrated.promise - await Promise.resolve() - await Promise.resolve() - const view = lease.currentView() - expect(view.data?.state.name).toBe('Hydrated') - expect(view.data?.priority).toBe(4) - startTransition(() => - route.setSelectedLinearIssue(view.data, { - expectedSelection: route.linearSelectionRequest, - isCurrent: () => lease.commit(view, false, () => true) - }) - ) - }) - expect(renders).toContain('Hydrated:4') - expect(commits.includes('Hydrated:4')).toBe(!suspend) - // A real later field write retires the delivered read but must not erase pending priority. - act(() => { - titleSaving = writeLinearIssue( - task(), - { title: 'Current title' }, - context, - () => pendingTitle.promise - ) - pulse() - }) - block = false - await act(async () => { - pulse() - }) - const visible = screen.getByTestId('hydration-scope').textContent - const { projectLinearIssue } = - await import('@/store/slices/linear/linear-issue-write-publication') - expect(projectLinearIssue(getLinearSourceScope(context), task()).priority).toBe(4) - try { - expect(visible).toContain('Current title:4:') - } finally { - mounted.unmount() - lease.release() - pendingTitle.resolve({ ok: false }) - pendingPriority.resolve({ ok: false }) - pendingEarlierTitle.resolve({ ok: false }) - await Promise.all([prioritySaving, titleSaving, earlierTitleSaving]) - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - } - } -) - -it('committed hydration survives actual skipped-lane replay, force loading and terminal failure', async () => { - const s = store() - const first = deferred(), - h = deferred() - let rejectReplacement!: (error: Error) => void - const replacement = new Promise((_resolve, reject) => { - rejectReplacement = reject - }) - reads.detail - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(h.promise) - .mockReturnValueOnce(replacement) - const pending = deferred<{ ok: boolean }>(), - blocked = deferred() - let hold = true - let enroll!: () => void - let saving!: Promise<{ ok: boolean }> - const committed: string[] = [], - rendered: string[] = [] - function Detail() { - const [low, setLow] = useState(false) - const detail = useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: defaultScopeSource(null), - requestKey: 'H' - }) - const value = `${detail.displayed.priority}:${detail.editState.priority}:${detail.issueLoading}:${low}` - rendered.push(value) - useLayoutEffect(() => { - committed.push(value) - enroll = () => setLow(true) - }) - if (low && hold) { - throw blocked.promise - } - return {value} - } - const mounted = render( - Blocked}> - - - ) - await act(async () => { - startTransition(() => { - enroll() - saving = writeLinearIssue( - task(), - { priority: 4 }, - defaultScopeSource(null), - () => pending.promise - ) - }) - }) - expect(rendered.some((value) => value.startsWith('4:4:') && value.endsWith(':true'))).toBe(true) - expect(committed.some((value) => value.startsWith('4:4:'))).toBe(false) - await act(async () => { - pending.resolve({ ok: false }) - await saving - }) - expect(pendingLinearIssueWrites.size).toBe(0) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - h.resolve({ ...task('H'), priority: 2 }) - await h.promise - }) - expect(screen.getByTestId('replay').textContent).toBe('2:2:false:false') - act(() => { - s.getState().fetchLinearIssue(task().id, 'w', { force: true }) - }) - expect(reads.detail).toHaveBeenCalledTimes(3) - expect(screen.getByTestId('replay').textContent).toBe('2:2:true:false') - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('replay').textContent).toBe('2:2:true:true') - await act(async () => { - rejectReplacement(new Error('offline')) - await replacement.catch(() => {}) - }) - expect(screen.getByTestId('replay').textContent).toBe('2:2:false:true') - expect(reads.detail).toHaveBeenCalledTimes(3) - reads.detail.mockResolvedValueOnce({ ...task('G'), priority: 3 }) - await act(async () => { - s.getState().fetchLinearIssue(task().id, 'w', { force: true }) - }) - expect(screen.getByTestId('replay').textContent).toBe('3:3:false:true') - mounted.unmount() - first.resolve(task('retired')) - await first.promise - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - act(() => s.getState().patchLinearIssue(task().id, { priority: 4 })) - expect(reads.detail).toHaveBeenCalledTimes(4) -}) - -it('committed field t reconstructs while newer u is in a genuinely suspended lane', async () => { - store() - const t = deferred<{ ok: boolean }>(), - u = deferred<{ ok: boolean }>(), - blocked = deferred() - let savingT!: Promise<{ ok: boolean }>, savingU!: Promise<{ ok: boolean }> - let enqueue!: () => void, pulse!: () => void - let hold = true - const commits: string[] = [] - function Consumer() { - const [low, setLow] = useState(false), - [tick, setTick] = useState(0) - const publication = useLinearIssuePublication(task(), defaultScopeSource(null)) - const value = `${publication.issue?.priority}:${low}:${tick}` - useLayoutEffect(() => { - enqueue = () => setLow(true) - pulse = () => setTick((n) => n + 1) - commits.push(value) - }) - if (low && hold) { - throw blocked.promise - } - return {value} - } - const mounted = render( - Blocked}> - - - ) - act(() => { - savingT = writeLinearIssue(task(), { priority: 4 }, defaultScopeSource(null), () => t.promise) - }) - expect(screen.getByTestId('field-replay').textContent).toBe('4:false:0') - await act(async () => { - startTransition(() => { - enqueue() - savingU = writeLinearIssue(task(), { priority: 2 }, defaultScopeSource(null), () => u.promise) - }) - }) - expect(commits.some((value) => value.startsWith('2:'))).toBe(false) - act(() => pulse()) - expect(screen.getByTestId('field-replay').textContent).toBe('4:false:1') - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('field-replay').textContent).toBe('2:true:1') - mounted.unmount() - t.resolve({ ok: false }) - u.resolve({ ok: false }) - await Promise.all([savingT, savingU]) - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) -}) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx deleted file mode 100644 index 4a8a64338f6..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -// @vitest-environment happy-dom -import { reads, task } from './task-page-linear-refresh-test-fixtures' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -import { getIssue } from '../../../../../main/linear/issues/linear-issue-lookups' -import { createLinearClient, loadLinearSdk } from '../../../../../main/linear/requests/linear-sdk' -import { ISSUE_QUERY } from '../../../../../main/linear/issues/context/issue-context-graphql' - -const clients = vi.hoisted(() => ({ get: vi.fn() })) -vi.mock('../../../../../main/linear/accounts/linear-account-client', () => ({ - getClients: clients.get, - isAuthError: (error: unknown) => error instanceof loadLinearSdk().AuthenticationLinearError -})) -vi.mock('../../../../../main/linear/accounts/linear-token-store', () => ({ clearToken: vi.fn() })) - -const child = { - id: 'child', - identifier: 'DAT-125', - title: 'Child task', - url: 'https://linear.app/acme/issue/DAT-125' -} -const project = { - id: 'project', - name: 'Release', - url: 'https://linear.app/acme/project/release', - color: '#888888' -} -const rawIssue = { - ...task('Fresh'), - branchName: 'dat-124', - description: 'Full description', - priority: 2, - estimate: 3, - dueDate: '2026-11-10', - updatedAt: '2026-11-02T10:00:00-08:00', - assignee: { id: 'user', displayName: 'Ada', avatarUrl: 'https://example.com/avatar' }, - project, - labels: { nodes: [{ id: 'bug', name: 'Bug' }] }, - children: { nodes: [child] } -} -// The shared query returns the same first label page the drawer previously requested. -const { labelIds: _labelIds, ...responseIssue } = rawIssue -const expected = { - ...task('Fresh'), - branchName: rawIssue.branchName, - description: rawIssue.description, - priority: 2, - estimate: 3, - dueDate: rawIssue.dueDate, - updatedAt: '2026-11-02T18:00:00.000Z', - workspaceName: 'Acme', - assignee: rawIssue.assignee, - project, - labels: ['Bug'], - labelIds: ['bug'], - subIssues: [child] -} -let requests: { query: string; variables: Record }[] - -beforeEach(() => { - requests = [] - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { - requests.push(JSON.parse(String(init?.body))) - return Response.json({ data: { issue: responseIssue } }) - }) - clients.get.mockImplementation((_workspaceId: string, signal: AbortSignal) => [ - { - workspace: { id: 'w', organizationName: 'Acme' }, - client: createLinearClient({ apiKey: 'test-only', signal }) - } - ]) - reads.detail.mockImplementation((_source, id: string, workspaceId: string) => - getIssue(id, workspaceId) - ) -}) -afterEach(() => vi.restoreAllMocks()) - -function expectQuery(index: number, id: string) { - expect(requests[index]).toEqual({ - query: ISSUE_QUERY, - variables: { id, includeDrawerFields: true } - }) - expect(requests[index].query).toContain('children(first: 25)') - expect(clients.get).toHaveBeenLastCalledWith('w', expect.any(AbortSignal)) -} - -it('keeps readable core detail when an optional relation returns partial GraphQL errors', async () => { - vi.mocked(globalThis.fetch).mockImplementation(async (_input, init) => { - requests.push(JSON.parse(String(init?.body))) - return Response.json({ - data: { - issue: { ...responseIssue, assignee: { ...responseIssue.assignee, avatarUrl: null } } - }, - errors: [{ message: 'Avatar resolver failed', path: ['issue', 'assignee', 'avatarUrl'] }] - }) - }) - await expect(getIssue(task().id, 'w')).resolves.toEqual({ ...expected, assignee: undefined }) - expect(requests).toHaveLength(1) - expectQuery(0, task().id) -}) - -it.each([ - { path: ['issue', 'title'], extensions: undefined, auth: false }, - { path: ['issue', 'assignee'], extensions: { type: 'authentication error' }, auth: true } -])( - 'does not accept partial data for a core or authentication failure: $path', - async ({ path, extensions, auth }) => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(globalThis.fetch).mockResolvedValue( - Response.json({ - data: { issue: responseIssue }, - errors: [{ message: 'Read failed', path, extensions }] - }) - ) - const read = getIssue(task().id, 'w') - await (auth ? expect(read).rejects.toThrow('Linear') : expect(read).resolves.toBeNull()) - } -) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx deleted file mode 100644 index 9101632e7c9..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-jira-list-model.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import type { GitHubWorkItem } from '../../../../../shared/github/work-items/work-item-types' -import { getLinearPriorityLabel } from '@/components/task-page/task-page-localized-options' -import type { - LinearOrderBy, - LinearGroupBy, - LinearDisplayProperty -} from '../../../../../shared/linear/issue-view-resume-state' -import { translate } from '@/i18n/i18n' -import { useTranslate, type Translate } from '@/i18n/use-translate' -import type { TaskPageJiraLoadError } from '@/components/task-page/jira/task-page-jira-load-state' -import React from 'react' -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible' -import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react' -import { Button } from '@/components/ui/button' -import type { TaskPageGitHubMutationIntent } from '@/components/task-page/github/mutations/task-page-github-work-item-mutation-patches' -import type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import type { LinearGroupSection } from './task-page-linear-issue-model' -import { compareNumericLocaleText } from '../../../../../shared/locale-text-collators' -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() - 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()] -} -export function TaskPageJiraErrorBanner({ - error, - open, - onOpenChange -}: { - error: TaskPageJiraLoadError - open: boolean - onOpenChange: (open: boolean) => void -}): React.JSX.Element { - const t = useTranslate() - return ( - -
- -
-
{error.title}
- {error.details ? ( - <> - - - - -
- {error.details} -
-
- - ) : null} -
-
-
- ) -} -export function getLinearIssueGridTemplate( - visibleProperties: ReadonlySet -): string { - const columns = ['96px', 'minmax(240px,1.55fr)'] - if (visibleProperties.has('labels')) { - columns.push('minmax(168px,0.9fr)') - } - if (visibleProperties.has('team')) { - columns.push('minmax(172px,0.9fr)') - } - if (visibleProperties.has('state')) { - columns.push('138px') - } - if (visibleProperties.has('assignee')) { - columns.push('64px') - } - if (visibleProperties.has('updated')) { - columns.push('104px') - } - // Why: Worktrees is icon-only (open vs start); keep it narrow so issue title keeps the room. - columns.push('64px') - return columns.join(' ') -} -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 -} diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx deleted file mode 100644 index b4edb49b54f..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-entry.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -// @vitest-environment happy-dom -import { reads, task, store, source } from './task-page-linear-refresh-test-fixtures' - -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' - -import { useLinearIssueWorkspaceDetail } from '../../linear-issue-workspace-detail-state' -import type { LinearIssue } from '../../../../../shared/linear/issue-types' -import { expect, it } from 'vitest' -import { act, renderHook, waitFor } from '@testing-library/react' -import { deferred } from '../../../store/slices/linear/linear-slice-test-harness' - -it('workspace hydration follows a full-source change without a remount or a successor edge', async () => { - store() - const A = source('local'), - B = { ...A, accountLabel: 'B' } - const old = deferred(), - current = deferred() - reads.detail - .mockReturnValueOnce(old.promise) - .mockReturnValueOnce(current.promise) - .mockResolvedValue({ ...task('External'), priority: 2 }) - const detail = renderHook( - ({ context }) => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: context, - requestKey: 'unchanged-host-workspace-U' - }), - { initialProps: { context: A } } - ) - detail.rerender({ context: B }) - expect(reads.detail).toHaveBeenCalledTimes(2) - const pending = deferred<{ ok: boolean }>() - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue(task(), { priority: 4 }, B, () => pending.promise) - }) - await act(async () => { - old.resolve({ ...task('Old A'), priority: 0 }) - await old.promise - }) - expect(detail.result.current.displayed.priority).toBe(4) - expect(detail.result.current.displayed.state.name).not.toBe('Old A') - await act(async () => { - current.resolve({ ...task('Current B'), priority: 1 }) - await current.promise - }) - expect(detail.result.current.displayed.priority).toBe(4) - await act(async () => { - pending.resolve({ ok: false }) - await saving - }) - await waitFor(() => expect(detail.result.current.displayed.state.name).toBe('External')) - expect(detail.result.current.displayed.priority).toBe(2) - expect(detail.result.current.issueLoading).toBe(false) -}) - -it('actual runtime-status session loss and recovery reacquire hydration and finish loading', async () => { - const s = store() - const { createRuntimeStatusSlice } = - await import('@/store/slices/runtime-status/create-runtime-status-slice') - // SAFETY: this fixture installs the production runtime slice over the same Zustand store. - const runtime = createRuntimeStatusSlice( - 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. - s.getState as unknown as Parameters[1] - ) - // 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. - s.setState({ ...runtime, settings: { activeRuntimeEnvironmentId: 'remote' } as never }) - runtime.setRuntimeEnvironmentStatus( - 'remote', - { status: null, checkedAt: 1 }, - { suppressDisconnectToast: true } - ) - const old = deferred(), - fresh = deferred() - reads.detail.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise) - const detail = renderHook(() => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: source('runtime:remote'), - requestKey: 'same' - }) - ) - act(() => - detail.result.current.handleCommentAdded({ - id: 'local', - body: 'Draft comment', - createdAt: 'today' - }) - ) - await act(async () => { - // SAFETY: the real transition inspects runtime identity/readiness only; no snapshot or transport is used. - runtime.setRuntimeEnvironmentStatus( - 'remote', - { - // 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. - status: { - runtimeId: 'new', - graphStatus: 'ready', - rendererGraphEpoch: 0, - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 3 - } as never, - checkedAt: 2 - }, - { suppressDisconnectToast: true } - ) - }) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - old.resolve(task('Old session')) - fresh.resolve(task('New session')) - await Promise.all([old.promise, fresh.promise]) - }) - expect(detail.result.current.displayed.state.name).toBe('New session') - expect(detail.result.current.issueLoading).toBe(false) - expect(detail.result.current.comments.map((comment) => comment.id)).toContain('local') - const pending = deferred<{ ok: boolean }>() - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue( - task(), - { priority: 4 }, - source('runtime:remote'), - () => pending.promise - ) - }) - expect(reads.detail).toHaveBeenCalledTimes(2) - detail.unmount() - await act(async () => { - pending.resolve({ ok: false }) - await saving - }) -}) - -it.each(['probe', 'disconnect'] as const)( - 'foreign mounted hydration completes after local %s without another render edge', - async (edge) => { - const s = store() - const foreign = source('runtime:foreign') - const transport = deferred() - reads.detail.mockReturnValue(transport.promise) - const local = renderHook(() => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: defaultScopeSource(null), - requestKey: 'local-U' - }) - ) - const foreignKey = `${getLinearSourceScope(foreign).cachePrefix}::w::other` - const foreignCache = { data: task('Foreign cache'), fetchedAt: 1 } - s.setState({ linearIssueCache: { [foreignKey]: foreignCache } }) - const detail = renderHook(() => - useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: foreign, - requestKey: 'foreign-U' - }) - ) - expect(detail.result.current.issueLoading).toBe(true) - await act(async () => { - await (edge === 'probe' - ? s.getState().testLinearConnection('w') - : s.getState().disconnectLinear()) - }) - await act(async () => { - transport.resolve(task('Foreign provider')) - await transport.promise - }) - expect(detail.result.current.displayed.state.name).toBe('Foreign provider') - expect(detail.result.current.issueLoading).toBe(false) - expect(reads.detail).toHaveBeenCalledTimes(2) - expect(s.getState().linearIssueCache[foreignKey]).toBe(foreignCache) - if (edge === 'disconnect') { - expect(local.result.current.issueLoading).toBe(false) - expect(local.result.current.displayed.state.name).toBe('In Review') - } else { - expect(local.result.current.displayed.state.name).toBe('Foreign provider') - expect(local.result.current.issueLoading).toBe(false) - } - local.unmount() - detail.unmount() - } -) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx deleted file mode 100644 index 56badab631e..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-lifecycle.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// @vitest-environment happy-dom -import { - reads, - task, - store, - read, - loadingHook, - source -} from './task-page-linear-refresh-test-fixtures' -import '@/store/slices/linear/linear-issue-read' -import { - pendingLinearIssueWrites, - registerLinearIssueConsumer -} from '@/store/slices/linear/linear-issue-write-publication' -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 type { LinearIssue } from '../../../../../shared/linear/issue-types' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { act, waitFor } 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('keeps only own-query warm omissions, accepts new membership, and releases protection at settlement', async () => { - const s = store() - const untouched = { ...task(), id: 'untouched', title: 'untouched' } - reads.list.mockResolvedValueOnce({ items: [task(), untouched] }) - await read(s, 'list') - const next = { ...task('Server'), id: 'new', title: 'new' } - reads.list.mockResolvedValueOnce({ items: [next] }) - const pending = deferred<{ ok: boolean }>() - const hook = loadingHook(s) - let saving!: Promise<{ ok: boolean }> - act(() => { - saving = writeLinearIssue( - task(), - { state: task('Done').state }, - defaultScopeSource(null), - () => pending.promise - ) - }) - // A force dispatched during the pending interval may add rows, but cannot remove this query's protected occurrence. - reads.list.mockResolvedValueOnce({ items: [next] }) - hook.rerender({ nonce: 1 }) - await waitFor(() => - expect(hook.result.current.rows.map((row) => row.id)).toEqual(['same-uuid', 'new']) - ) - expect(hook.result.current.rows[0].state.name).toBe('Done') - reads.list.mockResolvedValueOnce({ items: [] }) - await act(async () => { - pending.resolve({ ok: true }) - await saving - }) - await waitFor(() => expect(hook.result.current.rows).toEqual([])) -}) - -it('dispatches despite a broken local subscriber and clears thrown writers independently of mount', async () => { - const s = store() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const registration = registerLinearIssueConsumer( - getLinearSourceScope(defaultScopeSource(null)), - task().id, - () => { - throw new Error('subscriber bug') - } - ) - const mutate = vi.fn(async () => { - throw new Error('transport failure') - }) - await act(async () => { - await expect( - writeLinearIssue(task(), { priority: 4 }, defaultScopeSource(null), mutate) - ).rejects.toThrow('transport failure') - }) - expect(mutate).toHaveBeenCalledTimes(1) - registration.release() - reads.detail.mockResolvedValue({ ...task('External'), priority: 2 }) - expect((await s.getState().fetchLinearIssue(task().id, 'w', { force: true }))?.priority).toBe(2) - warn.mockRestore() -}) - -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() -}) - -it('full disconnect detaches default and every explicit source, preserving another runtime and replacement owner', async () => { - const s = store() - const contexts = [ - defaultScopeSource(null), - source('local'), - { ...source('local'), accountLabel: 'second' }, - source('runtime:other') - ] - const pending = contexts.map(() => deferred<{ ok: boolean }>()) - const saves = contexts.map((context, i) => - writeLinearIssue(task(), { priority: 4 }, context, () => pending[i].promise) - ) - await s.getState().disconnectLinear() - const keys = contexts.map((context) => getLinearSourceScope(context).contextKey) - expect(keys.map((key) => pendingLinearIssueWrites.has(key))).toEqual([false, false, false, true]) - expect((await s.getState().connectLinear('fixture-not-a-credential')).ok).toBe(true) - reads.detail.mockResolvedValue({ ...task(), priority: 2 }) - const lease = s - .getState() - .fetchLinearIssue(task().id, 'w', { scope: getLinearSourceScope(contexts[1]), force: true }) - .acquire() - expect((await lease.waitForCurrentTerminal()).data?.priority).toBe(2) - const next = deferred<{ ok: boolean }>() - const replacement = writeLinearIssue( - { ...task(), priority: 2 }, - { priority: 3 }, - contexts[1], - () => next.promise - ) - await act(async () => { - for (let i = 0; i < 3; i++) { - pending[i].resolve({ ok: i === 2 }) - } - await Promise.all(saves.slice(0, 3)) - }) - expect(pendingLinearIssueWrites.has(keys[1])).toBe(true) - expect(pendingLinearIssueWrites.has(keys[3])).toBe(true) - expect(lease.currentView().data?.priority).toBe(3) - await act(async () => { - next.resolve({ ok: false }) - pending[3].resolve({ ok: true }) - await Promise.all([replacement, saves[3]]) - }) - expect((await lease.waitForCurrentTerminal()).data?.priority).toBe(2) - lease.release() - expect(pendingLinearIssueWrites.size).toBe(0) -}) - -it('a shared read gives explicit-runtime consumers authority after default focus moves', async () => { - const s = store(), - pending = deferred() - reads.detail.mockReturnValue(pending.promise) - const focused = s.getState().fetchLinearIssue(task().id, 'w').acquire() - const pinned = s - .getState() - .fetchLinearIssue(task().id, 'w', { scope: getLinearSourceScope({ kind: 'local' }) }) - .acquire() - expect(reads.detail).toHaveBeenCalledTimes(1) - // SAFETY: the fixture only reads the focused runtime selector from settings. - // 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. - s.setState({ settings: { activeRuntimeEnvironmentId: 'elsewhere' } as never }) - await act(async () => { - pending.resolve(task('Local')) - await pending.promise - }) - expect(focused.currentView().kind).toBe('abandoned') - expect(pinned.currentView().data?.state.name).toBe('Local') - focused.release() - pinned.release() -}) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures.tsx deleted file mode 100644 index b69852a36fc..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { useTaskPageDetailRouting } from '../use-task-page-detail-routing' -import type * as ReactModule from 'react' -import type { LinearEditState } from '../../linear-item-drawer-types' -import { useCallback } from 'react' -import { useLinearIssueResult } from '@/hooks/linear-issue-committed-state' -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 type { TaskSourceContext } from '../../../../../shared/work-items/task-source-context' -import { useTaskPageLinearCollectionEffectsPrelude } from './use-task-page-linear-collection-effects' -import { useTaskPageLinearCustomViewEffects } from './use-task-page-linear-custom-view-effects' -import { afterEach, beforeEach, vi, type Mock } from 'vitest' -import { cleanup, renderHook } 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' -import { useTaskPageLinearListSelectionPrelude } from './use-task-page-linear-list-selection' -import { useTaskPageLinearListEffects } from './use-task-page-linear-list-effects' - -const deferredIssueSetters = vi.hoisted(() => { - const updates: VoidFunction[] = [] - return { enabled: false, updates } -}) -vi.mock('react', async (original) => { - const actual = await original() - return { - ...actual, - useReducer(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', () => ({ - useAppStore: Object.assign( - (selector: (state: ReturnType) => unknown) => - useStore(active.store(), selector), - { - getState: () => active.store().getState(), - subscribe: (...args: Parameters) => active.store().subscribe(...args) - } - ) -})) - -vi.mock('@/i18n/i18n', async (original) => ({ - ...(await original>()), - translate: (_key: string, fallback: string, values?: Record) => - 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>()), - 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>()), - 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>()), - linearGetIssue: reads.detail, - linearIssueComments: async () => [], - linearUpdateIssue: reads.update, - linearCreateIssue: reads.create -})) - -vi.mock('@/hooks/useIssueMetadata', async (original) => ({ - ...(await original>()), - 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>()), useAllWorktrees: () => empty } -}) - -vi.mock('@/store/workspaces/catalog/workspace-record-fields', async (original) => { - const empty: [] = [] - return { ...(await original>()), 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 ? ( - - ) : ( -
- ) -})) - -export function DrawerFields({ row, editState }: { row: LinearIssue; editState: LinearEditState }) { - const controller = useLinearIssueEditController({ issue: row, editState }) - return ( - <> - - {row.state.name}:{editState.priority}:{editState.labels.join(',')} - - - - - - ) -} - -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 - -export type View = 'list' | 'search' | 'project' | 'custom-view' - -export const views: View[] = ['list', 'search', 'project', 'custom-view'] - -export const listArgs = { kind: 'list', filter: 'all', limit: 36 } as const - -export function read(s: Store, view: View, force = true) { - const state = s.getState() - if (view === 'search') { - return state.searchLinearIssues('DAT-124', 36, { force }) - } - if (view === 'project') { - return state.listLinearProjectIssues('p', 'w', 36, { force }) - } - if (view === 'custom-view') { - return state.listLinearCustomViewIssues('v', 'w', 36, { force }) - } - return state.listLinearIssues(listArgs, { force }) -} - -export function wire(view: View): Mock { - return view === 'custom-view' ? reads.view : reads[view] -} - -export function response(view: View, state: string) { - return view === 'search' ? [task(state)] : { items: [task(state)] } -} - -export function fixture(s: Store, rows: LinearIssue[], view: View = 'list') { - return { - settings: null, - linearStatus: s.getState().linearStatus, - selectedLinearWorkspaceId: 'w', - linearTaskSourceContext: null, - linearMode: 'issues', - linearAttributeFilter: { stateIds: [], priorities: [], assignee: null, labelIds: [] }, - linearAttributeFilterWorkspaceId: 'w', - linearIssues: rows, - linearIssueLimit: 36, - linearIssuePage: 0, - linearIssueLoadingTargetPage: null, - linearIssuesHasMore: false, - linearLoading: false, - linearError: null, - appliedLinearSearch: view === 'search' ? 'DAT-124' : '', - selectedLinearProject: - view === 'project' ? { id: 'p', workspaceId: 'w', name: 'Project' } : null, - linearProjectTab: view === 'project' ? 'issues' : 'overview', - linearProjectIssuesResult: { items: rows }, - linearProjectIssueLimit: 36, - linearProjectIssuePage: 0, - linearProjectIssueLoadingTargetPage: null, - linearProjectIssuesLoading: false, - linearProjectIssuesError: null, - selectedLinearCustomView: - view === 'custom-view' ? { id: 'v', workspaceId: 'w', model: 'issue', name: 'View' } : null, - linearCustomViewIssuesResult: { items: rows }, - linearCustomViewIssueLimit: 36, - linearCustomViewIssuePage: 0, - linearCustomViewIssueLoadingTargetPage: null, - linearCustomViewContentsLoading: false, - linearCustomViewContentsError: null, - availableTeams: [] - } -} - -export function display( - s: Store, - rows: LinearIssue[], - view: View = 'list', - context: TaskSourceContext | null = null -) { - const base = { ...fixture(s, rows, view), ...s.getState() } - const noop = vi.fn() - return renderHook(() => { - const scope = getLinearSourceScope(context ?? defaultScopeSource(null)) - const projectResult = useLinearIssueResult(`${scope.contextKey}project`, { items: rows }, scope) - const viewResult = useLinearIssueResult(`${scope.contextKey}view`, { items: rows }, scope) - const result = view === 'custom-view' ? viewResult : projectResult - const current = view === 'project' || view === 'custom-view' ? result.data.items : rows - const model = { - ...base, - linearTaskSourceContext: context, - linearIssues: current, - linearProjectIssueResult: projectResult, - linearCustomViewIssueResult: viewResult, - linearProjectIssuesResult: projectResult.data, - linearCustomViewIssuesResult: viewResult.data, - taskResumeApplied: true, - linearProjectSearchInput: '', - setAppliedLinearProjectSearch: noop, - filteredLinearIssues: rows, - clearSelectedLinearIssue: noop, - linearConnected: true, - taskSource: 'linear', - linearRefreshNonce: 0, - fetchLinearProject: async () => ({ id: 'p' }), - setSelectedLinearProjectDetail: noop, - setLinearProjectDetailLoading: noop, - setLinearProjectDetailError: noop, - setLinearProjectIssuesLoading: projectResult.setLoading, - setLinearProjectIssuesError: projectResult.setError, - setLinearCustomViewContentsLoading: viewResult.setLoading, - setLinearCustomViewContentsError: viewResult.setError, - setLinearCustomViewProjectsResult: noop, - setLinearProjectIssuesResult: projectResult.setData, - setLinearCustomViewIssuesResult: viewResult.setData - } - // SAFETY: fixture supplies the selected collection loader's fields; unrelated persistence is disabled. - // 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. - useTaskPageLinearCollectionEffectsPrelude({ - ...model, - selectedLinearProject: view === 'project' ? base.selectedLinearProject : null - } as never) - // SAFETY: fixture supplies the custom-view loader fields and no view inventory is requested. - // 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. - useTaskPageLinearCustomViewEffects({ ...model, linearMode: 'issues' } as never) - // List/search cache notifications are exercised with the real loader in loadingHook. - // SAFETY: fixture supplies the selection prelude fields. - // 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. - const selection = useTaskPageLinearListSelectionPrelude(model as never) - return { ...selection, loading: result.loading, error: result.error } - }) -} - -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 function loadingHook(s: Store, query = '', extra = {}, queued?: VoidFunction[]) { - const refs = { - linearSearchPersistReadyRef: { current: false }, - linearViewPersistReadyRef: { current: false }, - linearAttributeFilterReadRef: { current: null }, - lastLinearRequestRef: { current: null }, - landingLinearRefreshKeysRef: { current: new Set() } - } - const noop = vi.fn() - let renderCount = 0 - const base = { - ...fixture(s, [task()]), - ...refs, - ...s.getState(), - taskSource: 'linear', - linearConnected: true, - taskResumeApplied: true, - linearSearchInput: query, - appliedLinearSearch: query, - setTaskResumeState: noop, - setAppliedLinearSearch: noop, - setLinearIssueLimit: noop, - setLinearIssuePage: noop, - setLinearIssueLoadingTargetPage: noop, - setLinearIssuesHasMore: noop, - setLinearLoading: noop, - linearIssueFiltersByWorkspaceId: {}, - ...extra - } - return renderHook( - ({ nonce }) => { - renderCount++ - const scope = getLinearSourceScope(defaultScopeSource(null)) - const result = useLinearIssueResult(scope.contextKey + query, [task()], scope) - const { data: rows, error, loading, read: consumeLinearRead } = result - const deliver = useCallback( - (lease, view, value) => { - if (queued && 'data' in value) { - queued.push(() => consumeLinearRead(lease, view, value)) - // Preserve the original delayed-row seam: outcome consumption is immediate. - const { data: _rows, ...outcome } = value - consumeLinearRead(lease, view, outcome) - } else { - consumeLinearRead(lease, view, value) - } - }, - [consumeLinearRead] - ) - // SAFETY: fixture supplies the loader's dependencies; persistence setters are inert in this hook test. - // 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. - useTaskPageLinearListEffects({ - ...base, - linearRefreshNonce: nonce, - linearIssueResult: { ...result, read: deliver }, - setLinearIssues: result.setData, - setLinearError: result.setError, - setLinearLoading: result.setLoading, - setLinearIssuesHasMore: result.setHasMore - } as never) - // SAFETY: the same fixture supplies the rendered selection fields. - // 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. - const selection = useTaskPageLinearListSelectionPrelude({ - ...base, - linearIssues: rows, - linearError: error - } as never) - return { - rows, - error, - loading, - displayed: selection.displayedLinearIssues, - - renders: renderCount - } - }, - { initialProps: { nonce: 0 } } - ) -} - -export function source(hostId: TaskSourceContext['hostId']): TaskSourceContext { - return { - kind: 'task-source', - provider: 'linear', - hostId, - projectId: 'project', - repoId: null, - providerIdentity: null, - accountLabel: null - } -} - -export { reads } - -export function useRoutingFixture( - context: ReturnType | null, - initial: LinearIssue | null = null -) { - const noop = vi.fn() - // 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. - return useTaskPageDetailRouting({ - pageData: { openLinearIssue: initial ?? undefined }, - linearTaskSourceContext: context, - setDialogWorkItem: noop, - openTaskPage: noop - } as never) -} diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx deleted file mode 100644 index c9a395096b4..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-refresh-writers.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// @vitest-environment happy-dom -import { reads, task, store } from './task-page-linear-refresh-test-fixtures' -import { LinearIssueProjectSelector } from '../../linear-issue-project-selector' - -import '@/store/slices/linear/linear-issue-read' -import { pendingLinearIssueWrites } from '@/store/slices/linear/linear-issue-write-publication' -import { writeLinearIssue } from '@/lib/work-items/linear/linear-issue-write' - -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' - -import LinearItemDrawer from '../../LinearItemDrawer' - -import type { LinearIssue } from '../../../../../shared/linear/issue-types' - -import { useLinearIssueWorkspaceDetail } from '../../linear-issue-workspace-detail-state' -import { expect, it, vi } from 'vitest' -import { act, render, fireEvent, screen } from '@testing-library/react' -import { deferred } from '../../../store/slices/linear/linear-slice-test-harness' - -it('real drawer hydration, field controller and text writer compose rollback without permanent masks', async () => { - const s = store() - s.setState({ - recordFeatureInteraction: vi.fn(), - linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'B' } - }) - const old = deferred(), - fresh = deferred() - reads.detail.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise) - const priority = deferred<{ ok: boolean }>(), - labels = deferred<{ ok: boolean }>(), - title = deferred<{ ok: boolean }>() - reads.update.mockImplementation((_source, _id, patch) => - 'priority' in patch ? priority.promise : 'labelIds' in patch ? labels.promise : title.promise - ) - render() - expect(reads.detail).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByRole('button', { name: 'Priority' })) - fireEvent.click(screen.getByRole('button', { name: 'Label' })) - fireEvent.change(screen.getByRole('textbox', { name: 'Issue title' }), { - target: { value: 'Local title' } - }) - fireEvent.blur(screen.getByRole('textbox', { name: 'Issue title' })) - expect(reads.update).toHaveBeenCalledWith( - expect.anything(), - task().id, - { labelIds: ['bug'] }, - 'w' - ) - expect(screen.getByTestId('drawer').textContent).toBe('In Review:4:Bug') - await act(async () => { - old.resolve(task('Stale')) - await old.promise - labels.resolve({ ok: true }) - await labels.promise - }) - expect(screen.getByTestId('drawer').textContent).toBe('In Review:4:Bug') - await act(async () => { - await writeLinearIssue( - task(), - { title: 'Sibling title' }, - defaultScopeSource(null), - async () => ({ ok: true }) - ) - }) - await act(async () => { - priority.resolve({ ok: false }) - title.resolve({ ok: false }) - await Promise.all([priority.promise, title.promise]) - }) - expect(screen.getByTestId('drawer').textContent).toBe('In Review:0:Bug') - expect(screen.getByRole('textbox', { name: 'Issue title' }).value).toBe( - 'Sibling title' - ) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - fresh.resolve({ - ...task('External'), - title: 'Server title', - priority: 2, - labels: [], - labelIds: [] - }) - await fresh.promise - }) - expect(screen.getByTestId('drawer').textContent).toBe('External:2:') - expect(screen.getByRole('textbox', { name: 'Issue title' }).value).toBe( - 'Server title' - ) -}) - -it('confirmed project selection retires old hydration and publishes through the mounted detail consumer', async () => { - store() - const old = deferred(), - external = deferred() - const pending = deferred<{ ok: boolean }>() - reads.detail.mockReturnValueOnce(old.promise).mockReturnValueOnce(external.promise) - reads.projects.mockResolvedValue({ items: [{ id: 'chosen', name: 'Chosen' }] }) - reads.update.mockReturnValueOnce(pending.promise) - function Detail() { - const detail = useLinearIssueWorkspaceDetail({ - issue: task(), - providerSettings: defaultScopeSource(null), - requestKey: 'project' - }) - return ( - <> - {detail.displayed.project?.name ?? 'None'} - - - ) - } - render() - fireEvent.click(screen.getByRole('button', { name: 'Add to project' })) - fireEvent.change(screen.getByPlaceholderText('Search projects'), { target: { value: 'Chosen' } }) - fireEvent.click(await screen.findByRole('button', { name: 'Chosen' })) - expect(screen.getByTestId('project').textContent).toBe('None') - expect(reads.update).toHaveBeenCalledWith( - defaultScopeSource(null), - task().id, - { projectId: 'chosen' }, - 'w' - ) - await act(async () => { - pending.resolve({ ok: true }) - await pending.promise - }) - expect(screen.getByTestId('project').textContent).toBe('Chosen') - expect(pendingLinearIssueWrites.size).toBe(0) - expect(reads.detail).toHaveBeenCalledTimes(2) - await act(async () => { - old.resolve(task()) - await old.promise - }) - expect(screen.getByTestId('project').textContent).toBe('Chosen') - await act(async () => { - external.resolve({ ...task(), project: { id: 'external', name: 'External' } }) - await external.promise - }) - expect(screen.getByTestId('project').textContent).toBe('External') -}) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx deleted file mode 100644 index 504988edbae..00000000000 --- a/src/renderer/src/components/task-page/linear/task-page-linear-selection-decisions.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// @vitest-environment happy-dom -import { - reads, - store, - source, - task, - useRoutingFixture -} from './task-page-linear-refresh-test-fixtures' -import { reads as issueReadRegistry } from '@/store/slices/linear/linear-issue-read-lifecycle' -import '@/store/slices/linear/linear-issue-read' -import { pendingLinearIssueWrites } from '@/store/slices/linear/linear-issue-write-publication' -import { deferred } from '@/store/slices/linear/linear-slice-test-harness' -import type { useTaskPageDetailRouting } from '../use-task-page-detail-routing' -import { expect, it } from 'vitest' -import { act, render, renderHook, screen } from '@testing-library/react' -import { Suspense, startTransition, useLayoutEffect } from 'react' - -it('independent urgent clear supersedes an uncommitted nonempty selection', async () => { - const s = store(), - context = source('local'), - blocked = deferred() - s.setState({ jiraIssueCache: {}, jiraSearchCache: {} }) - let route!: ReturnType - let hold = true - const seen: string[] = [], - commits: string[] = [] - function Consumer() { - const current = useRoutingFixture(context) - const id = current.selectedLinearIssueId ?? 'none' - seen.push(id) - useLayoutEffect(() => { - route = current - commits.push(id) - }) - if (id === 'B' && hold) { - throw blocked.promise - } - return {id} - } - const mounted = render( - Blocked}> - - - ) - try { - await act(async () => { - startTransition(() => route.setSelectedLinearIssue({ ...task('B'), id: 'B' })) - }) - expect(seen).toContain('B') - expect(commits).not.toContain('B') - expect(screen.getByTestId('independent-clear').textContent).toBe('none') - const initialRequest = route.linearSelectionRequest - act(() => route.clearSelectedLinearIssue()) - const clearRequest = route.linearSelectionRequest - expect(clearRequest).not.toBe(initialRequest) - expect(screen.getByTestId('independent-clear').textContent).toBe('none') - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('independent-clear').textContent).toBe('none') - expect(route.linearSelectionRequest).toBe(clearRequest) - } finally { - mounted.unmount() - hold = false - blocked.resolve() - await blocked.promise - expect(commits).not.toContain('B') - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - expect(reads.detail).toHaveBeenCalledTimes(0) - } -}) - -it('same-batch explicit Clear wins while an obsolete observed request cannot clear a newer decision', () => { - store().setState({ jiraIssueCache: {}, jiraSearchCache: {} }) - const hook = renderHook(() => useRoutingFixture(source('local'))) - const request = hook.result.current.linearSelectionRequest - act(() => { - hook.result.current.setSelectedLinearIssue(task()) - hook.result.current.clearSelectedLinearIssue() - }) - expect(hook.result.current.selectedLinearIssue).toBeNull() - const clearRequest = hook.result.current.linearSelectionRequest - expect(clearRequest).not.toBe(request) - hook.rerender() - expect(hook.result.current.linearSelectionRequest).toBe(clearRequest) - act(() => { - hook.result.current.setSelectedLinearIssue(task()) - hook.result.current.setSelectedLinearIssue(null, { expectedSelection: clearRequest }) - }) - expect(hook.result.current.selectedLinearIssue?.id).toBe(task().id) - hook.unmount() - expect(pendingLinearIssueWrites.size).toBe(0) - expect(issueReadRegistry.size).toBe(0) - expect(reads.detail).not.toHaveBeenCalled() -}) diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts b/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts index 20835165110..eaf95a66938 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts +++ b/src/renderer/src/components/task-page/linear/use-task-page-linear-view-state.ts @@ -9,7 +9,7 @@ import { buildLinearIssueWorkspaceAttachmentIndex } from '@/lib/work-items/linea import { collectLinkedLinearIssueRefsFromWorktrees, linkedLinearIssueRefsSignature -} from '@/components/task-page/linear/task-page-linear-in-orca-issues' +} from '@/components/task-page/linear/issues/task-page-linear-in-orca-issues' import type { LinearMode } from '@/components/task-page/task-page-localized-options' import { type LinearViewMode, @@ -33,8 +33,8 @@ import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/is import type { LinearIssueListFilterRead, LinearPrimaryTeamObservation -} from '@/components/task-page/linear/task-page-linear-issue-request' -import type { LinearProjectTab } from './task-page-linear-issue-model' +} from '@/components/task-page/linear/issues/task-page-linear-issue-request' + import { useTaskPageLinearContextState } from './use-task-page-linear-context-state' export type TaskPageLinearViewStatePreludeModel = ReturnType< typeof useTaskPageLinearViewStatePrelude @@ -255,3 +255,5 @@ export function useTaskPageLinearViewState(model: TaskPageDetailRoutingModel) { return useTaskPageLinearContextState(preludeModel) } export type TaskPageLinearViewStateModel = ReturnType + +export type LinearProjectTab = 'overview' | 'issues' diff --git a/src/renderer/src/components/task-page/linear/CustomViewList.tsx b/src/renderer/src/components/task-page/linear/views/CustomViewList.tsx similarity index 96% rename from src/renderer/src/components/task-page/linear/CustomViewList.tsx rename to src/renderer/src/components/task-page/linear/views/CustomViewList.tsx index 9654c1fcd8a..69f7d035a04 100644 --- a/src/renderer/src/components/task-page/linear/CustomViewList.tsx +++ b/src/renderer/src/components/task-page/linear/views/CustomViewList.tsx @@ -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 { LinearCustomViewTable, diff --git a/src/renderer/src/components/task-page/linear/CustomViewProjects.tsx b/src/renderer/src/components/task-page/linear/views/CustomViewProjects.tsx similarity index 97% rename from src/renderer/src/components/task-page/linear/CustomViewProjects.tsx rename to src/renderer/src/components/task-page/linear/views/CustomViewProjects.tsx index c9a0f7ccc8f..12fa0487dcb 100644 --- a/src/renderer/src/components/task-page/linear/CustomViewProjects.tsx +++ b/src/renderer/src/components/task-page/linear/views/CustomViewProjects.tsx @@ -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, ExternalLink } from 'lucide-react' diff --git a/src/renderer/src/components/task-page/linear/use-task-page-linear-custom-view-effects.ts b/src/renderer/src/components/task-page/linear/views/use-task-page-linear-custom-view-effects.ts similarity index 86% rename from src/renderer/src/components/task-page/linear/use-task-page-linear-custom-view-effects.ts rename to src/renderer/src/components/task-page/linear/views/use-task-page-linear-custom-view-effects.ts index 097e5fa787d..7141fbc214b 100644 --- a/src/renderer/src/components/task-page/linear/use-task-page-linear-custom-view-effects.ts +++ b/src/renderer/src/components/task-page/linear/views/use-task-page-linear-custom-view-effects.ts @@ -1,16 +1,16 @@ import { observeLinearIssueRead } from '@/store/slices/linear/linear-issue-read-lifecycle' import { linearIssueReadError } from '@/lib/work-items/linear/linear-read-error' import { taskProviderErrorText } from '@/lib/work-items/task-provider-error-copy' -import type { TaskPageLinearInOrcaEffectsModel } from './use-task-page-linear-in-orca-effects' +import type { TaskPageLinearInOrcaEffectsModel } from '../issues/use-task-page-linear-in-orca-effects' import { useEffect } from 'react' -import { - LINEAR_CUSTOM_VIEW_MODELS, - mergeLinearCollectionResults -} from './task-page-linear-issue-model' -import { LINEAR_ITEM_LIMIT } from '../task-page-source-context' -import type { LinearCollectionResult } from '../../../../../shared/linear/workspace-types' -import type { LinearCustomViewSummary } from '../../../../../shared/linear/project-types' -import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' + +import { LINEAR_ITEM_LIMIT } from '../../task-page-source-context' +import type { LinearCollectionResult } from '../../../../../../shared/linear/workspace-types' +import type { + LinearCustomViewSummary, + LinearCustomViewModel +} from '../../../../../../shared/linear/project-types' +import { clampLinearIssueListLimit } from '../../../../../../shared/linear/issue-read-limits' export function useTaskPageLinearCustomViewEffects(model: TaskPageLinearInOrcaEffectsModel) { const { getCachedLinearCustomViews, @@ -209,3 +209,27 @@ export function useTaskPageLinearCustomViewEffects(model: TaskPageLinearInOrcaEf export type TaskPageLinearCustomViewEffectsModel = ReturnType< typeof useTaskPageLinearCustomViewEffects > + +export const LINEAR_CUSTOM_VIEW_MODELS = [ + 'issue', + 'project' +] satisfies readonly LinearCustomViewModel[] + +export function mergeLinearCollectionResults( + results: LinearCollectionResult[] +): LinearCollectionResult { + 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 + } + : {}) + } +} diff --git a/src/renderer/src/components/task-page/use-task-page-composer-actions.ts b/src/renderer/src/components/task-page/use-task-page-composer-actions.ts index 9379dc5c750..0f5f2c7795d 100644 --- a/src/renderer/src/components/task-page/use-task-page-composer-actions.ts +++ b/src/renderer/src/components/task-page/use-task-page-composer-actions.ts @@ -1,4 +1,4 @@ -import type { TaskPageJiraListEffectsModel } from './jira/use-task-page-jira-list-effects' +import type { TaskPageJiraListEffectsModel } from './jira/listing/use-task-page-jira-list-effects' import { useCallback } from 'react' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { LinearWorkspaceSelection } from '../../../../shared/linear/workspace-types' diff --git a/src/renderer/src/components/task-page/use-task-page-detail-routing.ts b/src/renderer/src/components/task-page/use-task-page-detail-routing.ts index 3b0fc51893d..991de5e4241 100644 --- a/src/renderer/src/components/task-page/use-task-page-detail-routing.ts +++ b/src/renderer/src/components/task-page/use-task-page-detail-routing.ts @@ -11,7 +11,7 @@ import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { JiraIssue } from '../../../../shared/jira/jira-types' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' -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' export function useTaskPageDetailRouting(model: TaskPageGitHubIssueDraftModel) { const { pageData, diff --git a/src/renderer/src/components/task-page/use-task-page-global-effects.ts b/src/renderer/src/components/task-page/use-task-page-global-effects.ts index 528bc6108dc..1d12b1b3c37 100644 --- a/src/renderer/src/components/task-page/use-task-page-global-effects.ts +++ b/src/renderer/src/components/task-page/use-task-page-global-effects.ts @@ -1,4 +1,4 @@ -import type { TaskPageJiraSummaryGenerationModel } from './jira/use-task-page-jira-summary-generation' +import type { TaskPageJiraSummaryGenerationModel } from './jira/creation/use-task-page-jira-summary-generation' import { useEffect } from 'react' import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router' export function useTaskPageGlobalEffects(model: TaskPageJiraSummaryGenerationModel) { diff --git a/src/renderer/src/components/task-page/use-task-page-source-display.ts b/src/renderer/src/components/task-page/use-task-page-source-display.ts index 880a4da8946..77b076b586a 100644 --- a/src/renderer/src/components/task-page/use-task-page-source-display.ts +++ b/src/renderer/src/components/task-page/use-task-page-source-display.ts @@ -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 { useMemo } from 'react' import type { GitHubTaskKind } from '../../../../shared/work-items/task-github-search' import { buildGitHubRepoUrl } from '../../../../shared/github/repositories/links' diff --git a/src/renderer/src/components/use-task-creation-draft-retention.test.ts b/src/renderer/src/components/use-task-creation-draft-retention.test.ts index 6d6ecfc4fda..b3af1723f8e 100644 --- a/src/renderer/src/components/use-task-creation-draft-retention.test.ts +++ b/src/renderer/src/components/use-task-creation-draft-retention.test.ts @@ -1,407 +1,16 @@ // @vitest-environment happy-dom -import { act, renderHook, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { expect, it } from 'vitest' import { useTaskCreationDraftRetention } from './use-task-creation-draft-retention' -import { store, reads } from './task-page/linear/task-page-linear-refresh-test-fixtures' -import { - useLinearIssueCreationForm, - useTaskPageLinearCreationState -} from './task-page/linear/use-task-page-linear-creation-state' -import { useTaskPageJiraCreationStatePrelude } from './task-page/jira/use-task-page-jira-creation-state' +import { store } from './task-page/linear/issue-refresh/task-page-linear-refresh-test-fixtures' + import { writeNewLinearProjectDraft, writeNewJiraIssueDraft } from './task-page/task-page-draft-storage' -import { deferred } from '../store/slices/linear/linear-slice-test-harness' -import { - createElement, - Suspense, - startTransition, - useLayoutEffect, - useCallback, - useEffect, - useState -} from 'react' - -type Draft = { title: string; body: string } - -const emptyDraft = (): Draft => ({ title: '', body: '' }) - -describe('useTaskCreationDraftRetention', () => { - it('does not fan out writes while typing and saves the latest text on dismissal', () => { - const writeDraft = vi.fn() - const view = renderHook( - ({ open, draft }) => useTaskCreationDraftRetention({ open, draft, writeDraft }), - { initialProps: { open: true, draft: emptyDraft() } } - ) - - view.rerender({ open: true, draft: { title: 'Bug', body: '' } }) - view.rerender({ open: true, draft: { title: 'Bug', body: 'Latest details' } }) - - expect(writeDraft).not.toHaveBeenCalled() - - view.rerender({ open: false, draft: emptyDraft() }) - - expect(writeDraft).toHaveBeenCalledOnce() - expect(writeDraft).toHaveBeenCalledWith({ title: 'Bug', body: 'Latest details' }) - }) - - it('saves the latest text when an open composer unmounts', () => { - const writeDraft = vi.fn() - const view = renderHook( - ({ draft }) => useTaskCreationDraftRetention({ open: true, draft, writeDraft }), - { initialProps: { draft: { title: 'First', body: '' } } } - ) - view.rerender({ draft: { title: 'Latest', body: 'Details' } }) - - view.unmount() - - expect(writeDraft).toHaveBeenCalledOnce() - expect(writeDraft).toHaveBeenCalledWith({ title: 'Latest', body: 'Details' }) - }) -}) - -it.each([false, true])( - 'real skipped title and replayed body/completion retain the whole successor (close before success=$0)', - async (closeBeforeSuccess) => { - const s = store(), - blocked = deferred() - const save = vi.spyOn(s.getState(), 'setNewLinearIssueDraft') - const clear = vi.spyOn(s.getState(), 'clearNewLinearIssueDraft') - let form!: ReturnType, low!: () => void - let hold = true - const renders: string[] = [], - commits: string[] = [] - function Composer() { - const [pending, setPending] = useState(false) - const current = useLinearIssueCreationForm('R1', { - open: true, - title: 'Original', - body: 'Body' - }) - const value = `${current.newLinearIssueTitle}/${current.newLinearIssueBody}/${current.newLinearIssueOpen}` - renders.push(value) - useLayoutEffect(() => { - form = current - low = () => setPending(true) - commits.push(value) - }) - if (pending && hold) { - throw blocked.promise - } - return createElement('output', { 'data-testid': 'draft-queue' }, value) - } - const view = render(createElement(Suspense, { fallback: 'Held' }, createElement(Composer))) - const titleSetter = form.setNewLinearIssueTitle - await act(async () => { - startTransition(() => { - form.setNewLinearIssueTitle('Queued title') - low() - }) - }) - expect(renders).toContain('Queued title/Body/true') - expect(commits).not.toContain('Queued title/Body/true') - act(() => form.setNewLinearIssueBody('Current body')) - const submitted = form.captureNewLinearIssueDraft() - expect(save).not.toHaveBeenCalled() - expect(clear).not.toHaveBeenCalled() - if (closeBeforeSuccess) { - act(() => form.setNewLinearIssueOpen(false)) - expect(s.getState().newLinearIssueDraft).toBe(submitted.draft) - expect(s.getState().newLinearIssueDraft).toEqual({ title: 'Original', body: 'Current body' }) - } - act(() => form.completeNewLinearIssueDraft(submitted)) - expect(s.getState().newLinearIssueDraft).toBeNull() - expect(form.newLinearIssueOpen).toBe(false) - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(screen.getByTestId('draft-queue').textContent).toBe( - `Queued title/Current body/${!closeBeforeSuccess}` - ) - expect(form.setNewLinearIssueTitle).toBe(titleSetter) - if (closeBeforeSuccess) { - const saved = s.getState().newLinearIssueDraft - expect.soft(saved).toEqual({ title: 'Queued title', body: 'Current body' }) - act(() => { - form.setNewLinearIssueTitle(saved?.title ?? '') - form.setNewLinearIssueBody(saved?.body ?? '') - form.setNewLinearIssueOpen(true) - }) - expect.soft(form.newLinearIssueTitle).toBe('Queued title') - expect.soft(form.newLinearIssueBody).toBe('Current body') - } else { - expect(s.getState().newLinearIssueDraft).toBeNull() - } - act(() => { - form.setNewLinearIssueTitle((v) => `${v} final`) - form.setNewLinearIssueBody((v) => `${v} final`) - form.setNewLinearIssueOpen(false) - }) - expect(s.getState().newLinearIssueDraft).toEqual({ - title: 'Queued title final', - body: 'Current body final' - }) - act(() => { - const saved = s.getState().newLinearIssueDraft! - form.setNewLinearIssueTitle(saved.title) - form.setNewLinearIssueBody(saved.body) - form.setNewLinearIssueOpen(true) - }) - expect(form).toMatchObject({ - newLinearIssueTitle: 'Queued title final', - newLinearIssueBody: 'Current body final', - newLinearIssueOpen: true - }) - view.unmount() - } -) - -it.each([ - { title: 'Old', body: 'Body' }, - { title: '', body: '' }, - { title: ' \t', body: '\n ' } -])( - 'queued old-runtime text composes before the actual prelude handoff, never after it (initial=$title/$body)', - async (initial) => { - const s = store(), - blocked = deferred() - let form!: ReturnType, - change!: (runtime: string) => void, - low!: () => void - let hold = true - const commits: string[] = [], - seen: string[] = [] - function Composer() { - const [runtime, setRuntime] = useState('R1'), - [pending, setPending] = useState(false) - const enroll = useCallback(() => setPending(true), []) - const current = useLinearIssueCreationForm(runtime, { open: true, ...initial }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real prelude consumes only Linear reset and inert Jira state on this runtime transition. - useTaskPageJiraCreationStatePrelude({ - ...current, - providerRuntimeContextKey: runtime, - setNewLinearIssueProjects: vi.fn(), - setNewLinearIssueProjectsLoading: vi.fn() - } as never) - const value = `${runtime}/${current.newLinearIssueTitle}/${current.newLinearIssueBody}/${current.newLinearIssueOpen}` - seen.push(value) - useLayoutEffect(() => { - form = current - change = setRuntime - low = enroll - commits.push(value) - }, [current, value, enroll]) - if (pending && hold) { - throw blocked.promise - } - return null - } - const view = render(createElement(Suspense, { fallback: null }, createElement(Composer))) - const old = form - await act(async () => { - startTransition(() => { - old.setNewLinearIssueTitle('Queued old') - old.setNewLinearIssueBody('Queued body') - low() - }) - }) - expect(seen).toContain('R1/Queued old/Queued body/true') - expect(commits).not.toContain('R1/Queued old/Queued body/true') - act(() => change('R2')) - expect(commits).toContain('R2///false') - expect(commits).not.toContain('R1/Queued old/Queued body/true') - expect(s.getState().newLinearIssueDraft).toEqual(initial.title.trim() ? initial : null) - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - expect(form.newLinearIssueOpen).toBe(false) - const saved = s.getState().newLinearIssueDraft - expect.soft(saved).toEqual({ title: 'Queued old', body: 'Queued body' }) - act(() => { - form.setNewLinearIssueTitle(saved?.title ?? '') - form.setNewLinearIssueBody(saved?.body ?? '') - form.setNewLinearIssueOpen(true) - }) - act(() => { - old.setNewLinearIssueTitle('Late old') - old.resetNewLinearIssue() - }) - expect.soft(form.newLinearIssueTitle).toBe('Queued old') - expect.soft(form.newLinearIssueBody).toBe('Queued body') - expect(form.newLinearIssueOpen).toBe(true) - view.unmount() - } -) - -it('current-target layout actions precede delayed runtime reset and obsolete queued completion/finally', async () => { - const s = store(), - blocked = deferred(), - oldAttempt = {}, - replacement = {} - let form!: ReturnType, - change!: (runtime: string) => void, - low!: () => void, - pulse!: () => void - let hold = true, - acquired = 0, - released = 0 - const commits: string[] = [], - seen: string[] = [] - s.setState({ persistedUIReady: false }) - reads.projects.mockResolvedValue({ items: [] }) - const teams = [{ id: 'team-1', workspaceId: 'w' }] - function Composer() { - const [runtime, setRuntime] = useState('R1'), - [pending, setPending] = useState(false), - [tick, setTick] = useState(0) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: actual creation state consumes these account/team/runtime inputs; tour readiness is disabled. - const current = useTaskPageLinearCreationState({ - providerRuntimeContextKey: runtime, - settings: null, - activeModal: 'none', - linearConnected: true, - selectedLinearWorkspaceId: 'w', - linearTaskSourceContext: null, - availableTeams: teams - } as never) - const { - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueOpen, - setNewLinearIssueStateId, - beginNewLinearIssue - } = current - const enroll = useCallback(() => setPending(true), []) - const advance = useCallback(() => setTick((n) => n + 1), []) - useLayoutEffect(() => { - if (runtime === 'R1') { - setNewLinearIssueTitle('Old') - setNewLinearIssueBody('Body') - setNewLinearIssueOpen(true) - } else { - setNewLinearIssueStateId('new-default') - setNewLinearIssueTitle('Replacement') - setNewLinearIssueBody('Replacement body') - setNewLinearIssueOpen(true) - beginNewLinearIssue(replacement) - } - }, [ - runtime, - setNewLinearIssueTitle, - setNewLinearIssueBody, - setNewLinearIssueOpen, - setNewLinearIssueStateId, - beginNewLinearIssue - ]) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: actual runtime reset owner receives its Linear inputs; Jira is closed and unrelated metadata callbacks are inert. - useTaskPageJiraCreationStatePrelude({ - ...current, - providerRuntimeContextKey: runtime - } as never) - useEffect(() => { - acquired++ - return () => { - released++ - } - }, [current.setNewLinearIssueTitle]) - const value = `${runtime}/${current.newLinearIssueTitle}/${current.newLinearIssueSubmitting}/${tick}` - seen.push(value) - useLayoutEffect(() => { - form = current - change = setRuntime - low = enroll - pulse = advance - commits.push(value) - }, [current, value, enroll, advance]) - if (pending && hold) { - throw blocked.promise - } - return null - } - const view = render(createElement(Suspense, { fallback: null }, createElement(Composer))) - act(() => form.beginNewLinearIssue(oldAttempt)) - const old = form, - submitted = old.captureNewLinearIssueDraft() - seen.length = 0 - commits.length = 0 - await act(async () => { - startTransition(() => { - old.completeNewLinearIssueDraft(submitted) - old.settleNewLinearIssue(oldAttempt) - low() - }) - }) - expect(seen).toContain('R1//false/0') - expect(commits).not.toContain('R1//false/0') - act(() => change('R2')) - expect(form).toMatchObject({ - newLinearIssueOpen: true, - newLinearIssueTitle: 'Replacement', - newLinearIssueBody: 'Replacement body', - newLinearIssueStateId: 'new-default', - newLinearIssueSubmitting: true - }) - hold = false - await act(async () => { - blocked.resolve() - await blocked.promise - }) - act(() => { - old.settleNewLinearIssue(oldAttempt) - old.setNewLinearIssueTitle('Late') - old.resetNewLinearIssue() - pulse() - }) - expect(form).toMatchObject({ - newLinearIssueOpen: true, - newLinearIssueTitle: 'Replacement', - newLinearIssueSubmitting: true - }) - expect(acquired).toBe(2) - expect(released).toBe(1) - act(() => form.settleNewLinearIssue(replacement)) - act(() => form.setNewLinearIssueOpen(false)) - expect(s.getState().newLinearIssueDraft).toEqual({ - title: 'Replacement', - body: 'Replacement body' - }) - view.unmount() - expect(released).toBe(2) -}) - -it('unchanged exact issue success stays disarmed through harmless closed renders and unmount', () => { - const s = store() - const save = vi.spyOn(s.getState(), 'setNewLinearIssueDraft') - const clear = vi.spyOn(s.getState(), 'clearNewLinearIssueDraft') - const view = renderHook(() => - useLinearIssueCreationForm('R1', { open: true, title: 'Created', body: 'Details' }) - ) - const submitted = view.result.current.captureNewLinearIssueDraft() - act(() => { - view.result.current.setNewLinearIssueTitle((value) => value) - view.result.current.setNewLinearIssuePriority(3) - }) - expect(view.result.current.captureNewLinearIssueDraft()).toBe(submitted) - expect(save).not.toHaveBeenCalled() - expect(clear).not.toHaveBeenCalled() - act(() => view.result.current.completeNewLinearIssueDraft(submitted)) - expect(view.result.current.newLinearIssueOpen).toBe(false) - expect(s.getState().newLinearIssueDraft).toBeNull() - act(() => view.result.current.setNewLinearIssuePriority(4)) - view.rerender() - expect(view.result.current.captureNewLinearIssueDraft()).toBe(submitted) - view.unmount() - expect(save).not.toHaveBeenCalled() - expect(clear).toHaveBeenCalledOnce() - expect(s.getState().newLinearIssueDraft).toBeNull() -}) it('project and Jira immediate discard keep their actual plain session slots cleared through close', () => { const s = store() diff --git a/tests/e2e/task-page-linear-refresh-authority.test.tsx b/tests/e2e/task-page-linear-refresh-authority.test.tsx deleted file mode 100644 index 45545c6a58e..00000000000 --- a/tests/e2e/task-page-linear-refresh-authority.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useLinearIssueResult } from '@/hooks/linear-issue-committed-state' -// @vitest-environment happy-dom -import { - reads, - task, - store, - fixture, - source -} from '../../src/renderer/src/components/task-page/linear/task-page-linear-refresh-test-fixtures' - -import { defaultScopeSource } from '@/runtime/hosts/selection/default-creation-host' -import { getLinearSourceScope } from '@/store/slices/linear/linear-slice-scope' - -import type { LinearIssue } from '../../src/shared/linear/issue-types' - -import { useTaskPageLinearInOrcaEffects } from '../../src/renderer/src/components/task-page/linear/use-task-page-linear-in-orca-effects' -import { useTaskPageDetailRouting } from '../../src/renderer/src/components/task-page/use-task-page-detail-routing' -import { useLinearIssueWorkspaceDetail } from '../../src/renderer/src/components/linear-issue-workspace-detail-state' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { act, renderHook, waitFor } from '@testing-library/react' - -import { getTaskSourceCacheScope } from '../../src/shared/work-items/task-source-context' - -const cacheTime = Date.parse('2026-01-01T00:00:00Z') -beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }) - vi.setSystemTime(cacheTime) -}) -afterEach(() => vi.useRealTimers()) - -it('retains ordered linked source provenance when two hosts return the same UUID', async () => { - const s = store() - const first = source('local'), - second = source('runtime:other') - const refs = [ - { identifier: 'DAT-124', workspaceId: 'w', sourceContext: first }, - { identifier: 'DAT-124', workspaceId: 'w', sourceContext: second } - ] - // Real detail actions create the requested source/identifier keys. - reads.detail.mockResolvedValueOnce(task()).mockResolvedValueOnce(task('Done')) - const base = { - ...fixture(s, []), - ...s.getState(), - taskResumeApplied: true, - taskSource: 'linear', - linearMode: 'in-orca', - linearConnected: true, - linearRefreshNonce: 0, - lastLinearRequestRef: { current: null }, - inOrcaLinkedLinearRefsRef: { current: refs }, - inOrcaLinkedLinearRefsSignature: 'two-hosts', - setLinearLoading: vi.fn(), - setLinearError: vi.fn(), - setLinearIssuesHasMore: vi.fn() - } - const hook = renderHook(() => { - const result = useLinearIssueResult( - 'two-hosts', - [], - getLinearSourceScope(defaultScopeSource(null)) - ) - // SAFETY: fixture supplies the linked loader fields. - // 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. - useTaskPageLinearInOrcaEffects({ - ...base, - linearIssueResult: result, - setLinearIssues: result.setData - } as never) - return result.data - }) - await waitFor(() => expect(hook.result.current).toHaveLength(2)) - act(() => - s - .getState() - .patchLinearIssue('same-uuid', { state: task('Blocked').state }, { sourceContext: first }) - ) - expect(hook.result.current.map((item) => item.state.name)).toEqual(['Blocked', 'Done']) - act(() => - s.getState().patchLinearIssue('same-uuid', { state: task().state }, { sourceContext: first }) - ) - expect(hook.result.current.map((item) => item.state.name)).toEqual(['In Review', 'Done']) -}) - -it('opens the fresh row on its origin source despite stale aliases and failed detail hydration', async () => { - const s = store(), - origin = source('runtime:origin'), - foreign = source('local') - s.setState({ - jiraIssueCache: {}, - jiraSearchCache: {}, - linearIssueCache: { - [`${getTaskSourceCacheScope(origin)}::w::DAT-124`]: { data: task(), fetchedAt: 1 }, - [`${getTaskSourceCacheScope(foreign)}::w::same-uuid`]: { data: task(), fetchedAt: cacheTime } - } - }) - reads.detail.mockRejectedValue(new Error('raw detail failure')) - const hook = renderHook(() => { - // SAFETY: routing fixture only opens Linear; Jira and callbacks are inert. - // 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. - const route = useTaskPageDetailRouting({ - pageData: { openLinearIssue: task('Done'), openLinearSourceContext: origin }, - linearTaskSourceContext: foreign, - setDialogWorkItem: vi.fn(), - openTaskPage: vi.fn() - } as never) - if (!route.linearDetailSourceContext) { - throw new Error('Expected the routed detail source') - } - const detail = useLinearIssueWorkspaceDetail({ - issue: route.selectedLinearIssue ?? task(), - providerSettings: route.linearDetailSourceContext, - requestKey: 'origin:uuid' - }) - return { route, detail } - }) - await waitFor(() => expect(hook.result.current.detail.issueLoading).toBe(false)) - expect(hook.result.current.detail.displayed.state.name).toBe('Done') - expect(reads.detail).toHaveBeenCalledWith(origin, 'same-uuid', 'w') -}) diff --git a/tests/e2e/task-page-linear-refresh-authority.unit.test.ts b/tests/e2e/task-page-linear-refresh-authority.unit.test.ts deleted file mode 100644 index 6ba3133ffcc..00000000000 --- a/tests/e2e/task-page-linear-refresh-authority.unit.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -// @vitest-environment happy-dom -import './task-page-linear-refresh-authority.test'