diff --git a/web/src/components/projects/pm-wizard-common-steps.tsx b/web/src/components/projects/pm-wizard-common-steps.tsx index 2c97c272..6032faef 100644 --- a/web/src/components/projects/pm-wizard-common-steps.tsx +++ b/web/src/components/projects/pm-wizard-common-steps.tsx @@ -57,6 +57,71 @@ function CopyButton({ text }: { text: string }) { ); } +// ============================================================================ +// LinearWebhookInfoPanel +// ============================================================================ + +export function LinearWebhookInfoPanel({ webhookUrl }: { webhookUrl: string }) { + return ( +
+
+
+ +
+

+ Manual Webhook Setup Required +

+

+ Linear webhooks must be configured manually in your Linear team settings. CASCADE + cannot create them programmatically. +

+
+
+
+ +
+ +
+ {webhookUrl} + +
+
+ +
+

Setup instructions:

+
    +
  1. + Go to{' '} + + linear.app/settings/api + {' '} + and navigate to Webhooks +
  2. +
  3. Click "New webhook" and enter the URL above
  4. +
  5. + Enable events: Issues (created, updated, removed) +
  6. +
  7. Select your team and save — webhooks are team-scoped in Linear
  8. +
  9. + Optionally set a webhook secret and store it as{' '} + LINEAR_WEBHOOK_SECRET in + project credentials +
  10. +
+
+
+ ); +} + +// ============================================================================ +// WebhookStep +// ============================================================================ + export function WebhookStep({ state, webhooksQuery, @@ -64,6 +129,7 @@ export function WebhookStep({ callbackBaseUrl, createWebhookMutation, deleteWebhookMutation, + linearWebhookUrl, }: { state: WizardState; webhooksQuery: WebhooksQueryProps; @@ -71,7 +137,17 @@ export function WebhookStep({ callbackBaseUrl: string; createWebhookMutation: UseMutationResult; deleteWebhookMutation: UseMutationResult; + linearWebhookUrl?: string; }) { + // Linear uses a display-only panel — no create/delete buttons + if (state.provider === 'linear') { + return ( + + ); + } + const isTrello = state.provider === 'trello'; const providerName = isTrello ? 'Trello' : 'JIRA'; diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 591ea0f8..836f1709 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -7,7 +7,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import type { WizardAction, WizardState } from './pm-wizard-state.js'; +import type { + LinearTeamDetails, + LinearTeamOption, + WizardAction, + WizardState, +} from './pm-wizard-state.js'; // ============================================================================ // Trello Discovery @@ -187,6 +192,99 @@ export function useJiraDiscovery( return { jiraProjectsMutation, jiraDetailsMutation, handleProjectSelect }; } +// ============================================================================ +// Linear Discovery +// ============================================================================ + +export function useLinearDiscovery( + state: WizardState, + dispatch: React.Dispatch, + advanceToStep: (step: number) => void, + projectId: string, +) { + const linearTeamsMutation = useMutation({ + mutationFn: () => { + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return trpcClient.integrationsDiscovery.linearTeamsByProject.mutate({ projectId }); + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching teams'); + } + return trpcClient.integrationsDiscovery.linearTeams.mutate({ + apiKey: state.linearApiKey, + }); + }, + onSuccess: (teams) => + dispatch({ + type: 'SET_LINEAR_TEAMS', + teams: teams as LinearTeamOption[], + }), + }); + + const linearDetailsMutation = useMutation({ + mutationFn: (teamId: string) => { + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return trpcClient.integrationsDiscovery.linearTeamDetailsByProject.mutate({ + projectId, + teamId, + }); + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching team details'); + } + return trpcClient.integrationsDiscovery.linearTeamDetails.mutate({ + apiKey: state.linearApiKey, + teamId, + }); + }, + onSuccess: (details) => { + dispatch({ + type: 'SET_LINEAR_TEAM_DETAILS', + details: details as LinearTeamDetails, + }); + advanceToStep(4); + }, + }); + + const handleTeamSelect = (teamId: string) => { + dispatch({ type: 'SET_LINEAR_TEAM_ID', id: teamId }); + if (teamId) { + linearDetailsMutation.mutate(teamId); + } + }; + + // Auto-fetch teams when verification result changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'linear') return; + if (state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { + linearTeamsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch team list and details + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds + useEffect(() => { + if (!state.isEditing || state.provider !== 'linear') return; + const canFetch = state.linearApiKey ? true : state.hasStoredCredentials; + if (canFetch && state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { + linearTeamsMutation.mutate(); + } + if ( + state.linearTeamId && + !state.linearTeamDetails && + canFetch && + !linearDetailsMutation.isPending + ) { + linearDetailsMutation.mutate(state.linearTeamId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.linearTeamId, state.hasStoredCredentials]); + + return { linearTeamsMutation, linearDetailsMutation, handleTeamSelect }; +} + // ============================================================================ // Verification // ============================================================================ @@ -209,6 +307,15 @@ export function useVerification( }); return { provider: 'trello' as const, result }; } + if (provider === 'linear') { + if (!state.linearApiKey) { + throw new Error('Enter your API key before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyLinear.mutate({ + apiKey: state.linearApiKey, + }); + return { provider: 'linear' as const, result }; + } if (!state.jiraEmail || !state.jiraApiToken) { throw new Error('Enter both credentials before verifying'); } @@ -228,6 +335,12 @@ export function useVerification( type: 'SET_VERIFICATION', result: { provider: 'trello', display: `@${r.username} (${r.fullName})` }, }); + } else if (provider === 'linear') { + const r = result as { name: string; displayName: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'linear', display: r.displayName || r.name }, + }); } else { const r = result as { displayName: string; emailAddress: string }; dispatch({ @@ -296,6 +409,22 @@ export function useWebhookManagement(projectId: string, state: WizardState) { }; } +// ============================================================================ +// Linear Webhook Info (display-only) +// ============================================================================ + +export function useLinearWebhookInfo() { + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const webhookUrl = callbackBaseUrl + ? `${callbackBaseUrl}/linear/webhook` + : '/linear/webhook'; + + return { webhookUrl }; +} + // ============================================================================ // Trello Label Creation // ============================================================================ @@ -454,7 +583,7 @@ export function useSaveMutation(projectId: string, state: WizardState) { const queryClient = useQueryClient(); const saveMutation = useMutation({ - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles two provider types + credential persisting + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles three provider types + credential persisting mutationFn: async () => { let config: Record; if (state.provider === 'trello') { @@ -464,6 +593,12 @@ export function useSaveMutation(projectId: string, state: WizardState) { labels: state.trelloLabelMappings, ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), }; + } else if (state.provider === 'linear') { + config = { + teamId: state.linearTeamId, + statuses: state.linearStatusMappings, + ...(Object.keys(state.linearLabels).length > 0 ? { labels: state.linearLabels } : {}), + }; } else { config = { projectKey: state.jiraProjectKey, @@ -502,6 +637,15 @@ export function useSaveMutation(projectId: string, state: WizardState) { name: 'Trello Token', }); } + } else if (state.provider === 'linear') { + if (state.linearApiKey) { + await trpcClient.projects.credentials.set.mutate({ + projectId, + envVarKey: 'LINEAR_API_KEY', + value: state.linearApiKey, + name: 'Linear API Key', + }); + } } else { if (state.jiraEmail) { await trpcClient.projects.credentials.set.mutate({ diff --git a/web/src/components/projects/pm-wizard-linear-steps.tsx b/web/src/components/projects/pm-wizard-linear-steps.tsx new file mode 100644 index 00000000..e07c406f --- /dev/null +++ b/web/src/components/projects/pm-wizard-linear-steps.tsx @@ -0,0 +1,184 @@ +/** + * Linear-specific step renderer components for PMWizard. + */ + +import type { UseMutationResult } from '@tanstack/react-query'; +import { CheckCircle2, Loader2 } from 'lucide-react'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import type { WizardAction, WizardState } from './pm-wizard-state.js'; +import { FieldMappingRow, SearchableSelect } from './wizard-shared.js'; + +// ============================================================================ +// Slot definitions +// ============================================================================ + +const LINEAR_STATUS_SLOTS = ['backlog', 'inProgress', 'inReview', 'done']; + +const LINEAR_LABEL_SLOTS = ['processing', 'processed', 'error', 'readyToProcess', 'auto']; + +// ============================================================================ +// LinearCredentialsStep +// ============================================================================ + +export function LinearCredentialsStep({ + state, + dispatch, +}: { + state: WizardState; + dispatch: React.Dispatch; +}) { + return ( +
+ {state.isEditing && state.hasStoredCredentials && !state.linearApiKey && ( +
+ + Credentials stored — enter new values below to replace them. +
+ )} +

+ Enter your Linear API key. This will be saved securely to the project. +

+
+ + dispatch({ type: 'SET_LINEAR_API_KEY', value: e.target.value })} + placeholder="lin_api_..." + autoComplete="off" + /> +

+ Generate a Personal API key at{' '} + + linear.app/settings/api + +

+
+
+ ); +} + +// ============================================================================ +// LinearTeamStep +// ============================================================================ + +export function LinearTeamStep({ + state, + onTeamSelect, + linearTeamsMutation, + linearDetailsMutation, +}: { + state: WizardState; + onTeamSelect: (id: string) => void; + linearTeamsMutation: UseMutationResult; + linearDetailsMutation: UseMutationResult; +}) { + return ( +
+ + ({ + label: t.name, + value: t.id, + detail: t.key, + }))} + value={state.linearTeamId} + onChange={onTeamSelect} + placeholder="Select a Linear team..." + isLoading={linearTeamsMutation.isPending} + error={linearTeamsMutation.isError ? (linearTeamsMutation.error as Error).message : null} + onRetry={() => + (linearTeamsMutation as UseMutationResult).mutate() + } + /> + {state.linearTeamId && linearDetailsMutation.isPending && ( +
+ Loading team details... +
+ )} +
+ ); +} + +// ============================================================================ +// LinearFieldMappingStep +// ============================================================================ + +export function LinearFieldMappingStep({ + state, + dispatch, +}: { + state: WizardState; + dispatch: React.Dispatch; +}) { + return ( +
+ {/* Status mappings */} +
+ +

+ Map each CASCADE status to a Linear workflow state in the team. +

+ {state.linearTeamDetails ? ( + LINEAR_STATUS_SLOTS.map((slot) => ( + ({ + label: s.name, + value: s.name, + })) ?? [] + } + value={state.linearStatusMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_LINEAR_STATUS_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a team first to populate status options. +

+ )} +
+ + {/* Labels */} +
+ +

+ CASCADE label names used in Linear. These are created automatically by CASCADE. +

+ {LINEAR_LABEL_SLOTS.map((slot) => ( +
+ {slot} + + dispatch({ + type: 'SET_LINEAR_LABEL', + key: slot, + value: e.target.value, + }) + } + placeholder={`Linear label for ${slot}`} + className="flex-1" + /> +
+ ))} +
+
+ ); +} diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 8be4e6d3..1a6bbe3d 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -31,7 +31,18 @@ export interface JiraProjectDetails { fields: Array<{ id: string; name: string; custom: boolean }>; } -export type Provider = 'trello' | 'jira'; +export interface LinearTeamOption { + id: string; + name: string; + key: string; +} + +export interface LinearTeamDetails { + states: Array<{ id: string; name: string; type: string }>; + labels: Array<{ id: string; name: string; color: string }>; +} + +export type Provider = 'trello' | 'jira' | 'linear'; export interface WizardState { provider: Provider; @@ -41,6 +52,7 @@ export interface WizardState { jiraEmail: string; jiraApiToken: string; jiraBaseUrl: string; + linearApiKey: string; verificationResult: { provider: Provider; display: string } | null; verifyError: string | null; // Step 3: Board/Project @@ -48,9 +60,12 @@ export interface WizardState { trelloBoards: TrelloBoardOption[]; jiraProjectKey: string; jiraProjects: JiraProjectOption[]; + linearTeamId: string; + linearTeams: LinearTeamOption[]; // Step 4: Field mapping trelloBoardDetails: TrelloBoardDetails | null; jiraProjectDetails: JiraProjectDetails | null; + linearTeamDetails: LinearTeamDetails | null; // Trello mappings trelloListMappings: Record; trelloLabelMappings: Record; @@ -60,6 +75,9 @@ export interface WizardState { jiraIssueTypes: Record; jiraLabels: Record; jiraCostFieldId: string; + // Linear mappings + linearStatusMappings: Record; + linearLabels: Record; // Editing mode isEditing: boolean; hasStoredCredentials: boolean; // true in edit mode when provider credentials exist in project_credentials @@ -72,6 +90,7 @@ export type WizardAction = | { type: 'SET_JIRA_EMAIL'; value: string } | { type: 'SET_JIRA_API_TOKEN'; value: string } | { type: 'SET_JIRA_BASE_URL'; url: string } + | { type: 'SET_LINEAR_API_KEY'; value: string } | { type: 'SET_VERIFICATION'; result: { provider: Provider; display: string } | null; @@ -81,6 +100,9 @@ export type WizardAction = | { type: 'SET_TRELLO_BOARD_ID'; id: string } | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } | { type: 'SET_JIRA_PROJECT_KEY'; key: string } + | { type: 'SET_LINEAR_TEAMS'; teams: LinearTeamOption[] } + | { type: 'SET_LINEAR_TEAM_ID'; id: string } + | { type: 'SET_LINEAR_TEAM_DETAILS'; details: LinearTeamDetails | null } | { type: 'SET_TRELLO_BOARD_DETAILS'; details: TrelloBoardDetails | null } | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } | { type: 'SET_TRELLO_LIST_MAPPING'; key: string; value: string } @@ -90,6 +112,8 @@ export type WizardAction = | { type: 'SET_JIRA_ISSUE_TYPE'; key: string; value: string } | { type: 'SET_JIRA_LABEL'; key: string; value: string } | { type: 'SET_JIRA_COST_FIELD'; id: string } + | { type: 'SET_LINEAR_STATUS_MAPPING'; key: string; value: string } + | { type: 'SET_LINEAR_LABEL'; key: string; value: string } | { type: 'INIT_EDIT'; state: Partial } | { type: 'ADD_TRELLO_BOARD_LABEL'; label: { id: string; name: string; color: string } } | { @@ -110,6 +134,14 @@ export const INITIAL_JIRA_LABELS: Record = { auto: 'cascade-auto', }; +export const INITIAL_LINEAR_LABELS: Record = { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', + auto: 'cascade-auto', +}; + export function createInitialState(): WizardState { return { provider: 'trello', @@ -118,14 +150,18 @@ export function createInitialState(): WizardState { jiraEmail: '', jiraApiToken: '', jiraBaseUrl: '', + linearApiKey: '', verificationResult: null, verifyError: null, trelloBoardId: '', trelloBoards: [], jiraProjectKey: '', jiraProjects: [], + linearTeamId: '', + linearTeams: [], trelloBoardDetails: null, jiraProjectDetails: null, + linearTeamDetails: null, trelloListMappings: {}, trelloLabelMappings: {}, trelloCostFieldId: '', @@ -133,6 +169,8 @@ export function createInitialState(): WizardState { jiraIssueTypes: {}, jiraLabels: { ...INITIAL_JIRA_LABELS }, jiraCostFieldId: '', + linearStatusMappings: {}, + linearLabels: { ...INITIAL_LINEAR_LABELS }, isEditing: false, hasStoredCredentials: false, }; @@ -179,6 +217,13 @@ export const wizardReducer: Reducer = (state, action) }; case 'SET_JIRA_BASE_URL': return { ...state, jiraBaseUrl: action.url, verificationResult: null, verifyError: null }; + case 'SET_LINEAR_API_KEY': + return { + ...state, + linearApiKey: action.value, + verificationResult: null, + verifyError: null, + }; case 'SET_VERIFICATION': return { ...state, verificationResult: action.result, verifyError: action.error ?? null }; case 'SET_TRELLO_BOARDS': @@ -203,6 +248,17 @@ export const wizardReducer: Reducer = (state, action) jiraIssueTypes: {}, jiraCostFieldId: '', }; + case 'SET_LINEAR_TEAMS': + return { ...state, linearTeams: action.teams }; + case 'SET_LINEAR_TEAM_ID': + return { + ...state, + linearTeamId: action.id, + linearTeamDetails: null, + linearStatusMappings: {}, + }; + case 'SET_LINEAR_TEAM_DETAILS': + return { ...state, linearTeamDetails: action.details }; case 'SET_TRELLO_BOARD_DETAILS': return { ...state, trelloBoardDetails: action.details }; case 'SET_JIRA_PROJECT_DETAILS': @@ -236,6 +292,16 @@ export const wizardReducer: Reducer = (state, action) }; case 'SET_JIRA_COST_FIELD': return { ...state, jiraCostFieldId: action.id }; + case 'SET_LINEAR_STATUS_MAPPING': + return { + ...state, + linearStatusMappings: { ...state.linearStatusMappings, [action.key]: action.value }, + }; + case 'SET_LINEAR_LABEL': + return { + ...state, + linearLabels: { ...state.linearLabels, [action.key]: action.value }, + }; case 'INIT_EDIT': return { ...state, ...action.state, isEditing: true }; case 'ADD_TRELLO_BOARD_LABEL': @@ -323,6 +389,16 @@ export function buildEditState( editState.hasStoredCredentials = configuredKeys.has('JIRA_EMAIL') && configuredKeys.has('JIRA_API_TOKEN'); + } else if (provider === 'linear') { + editState.linearTeamId = (initialConfig.teamId as string) ?? ''; + + const statuses = initialConfig.statuses as Record | undefined; + if (statuses) editState.linearStatusMappings = statuses; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.linearLabels = labels; + + editState.hasStoredCredentials = configuredKeys.has('LINEAR_API_KEY'); } return editState; @@ -341,22 +417,27 @@ export function isStep2Complete(state: WizardState): boolean { const credsReady = state.provider === 'trello' ? !!(state.trelloApiKey && state.trelloToken) - : !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl); + : state.provider === 'jira' + ? !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl) + : !!state.linearApiKey; return credsReady && !!state.verificationResult; } export function isStep3Complete(state: WizardState): boolean { - return state.provider === 'trello' ? !!state.trelloBoardId : !!state.jiraProjectKey; + if (state.provider === 'trello') return !!state.trelloBoardId; + if (state.provider === 'jira') return !!state.jiraProjectKey; + return !!state.linearTeamId; } export function isStep4Complete(state: WizardState): boolean { - return state.provider === 'trello' - ? Object.keys(state.trelloListMappings).length > 0 - : Object.keys(state.jiraStatusMappings).length > 0; + if (state.provider === 'trello') return Object.keys(state.trelloListMappings).length > 0; + if (state.provider === 'jira') return Object.keys(state.jiraStatusMappings).length > 0; + return Object.keys(state.linearStatusMappings).length > 0; } export function areCredentialsReady(state: WizardState): boolean { - return state.provider === 'trello' - ? !!(state.trelloApiKey && state.trelloToken) - : !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl); + if (state.provider === 'trello') return !!(state.trelloApiKey && state.trelloToken); + if (state.provider === 'jira') + return !!(state.jiraEmail && state.jiraApiToken && state.jiraBaseUrl); + return !!state.linearApiKey; } diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index 3fab7918..7a9c6c49 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -7,6 +7,8 @@ import { SaveStep, WebhookStep } from './pm-wizard-common-steps.js'; import { useJiraCustomFieldCreation, useJiraDiscovery, + useLinearDiscovery, + useLinearWebhookInfo, useSaveMutation, useTrelloCustomFieldCreation, useTrelloDiscovery, @@ -19,6 +21,11 @@ import { JiraFieldMappingStep, JiraProjectStep, } from './pm-wizard-jira-steps.js'; +import { + LinearCredentialsStep, + LinearFieldMappingStep, + LinearTeamStep, +} from './pm-wizard-linear-steps.js'; import { areCredentialsReady, buildEditState, @@ -122,6 +129,12 @@ export function PMWizard({ advanceToStep, projectId, ); + const { linearTeamsMutation, linearDetailsMutation, handleTeamSelect } = useLinearDiscovery( + state, + dispatch, + advanceToStep, + projectId, + ); const { createLabelMutation, createMissingLabelsMutation } = useTrelloLabelCreation( state, dispatch, @@ -129,6 +142,7 @@ export function PMWizard({ const { createCustomFieldMutation } = useTrelloCustomFieldCreation(state, dispatch); const { createJiraCustomFieldMutation } = useJiraCustomFieldCreation(state, dispatch); const webhookManagement = useWebhookManagement(projectId, state); + const { webhookUrl: linearWebhookUrl } = useLinearWebhookInfo(); const { saveMutation } = useSaveMutation(projectId, state); // ---- Label creation handlers ---- @@ -198,11 +212,13 @@ export function PMWizard({ url: w.callbackURL, active: w.active, })) - : (webhooksQuery.data?.jira ?? []).map((w) => ({ - id: String(w.id), - url: w.url, - active: w.enabled, - })); + : state.provider === 'jira' + ? (webhooksQuery.data?.jira ?? []).map((w) => ({ + id: String(w.id), + url: w.url, + active: w.enabled, + })) + : []; // Linear: webhooks are configured manually // ---- Render ---- @@ -219,7 +235,7 @@ export function PMWizard({
- {(['trello', 'jira'] as const).map((p) => ( + {(['trello', 'jira', 'linear'] as const).map((p) => ( ))}
@@ -251,6 +267,8 @@ export function PMWizard({ > {state.provider === 'trello' ? ( + ) : state.provider === 'linear' ? ( + ) : ( )} @@ -301,6 +319,13 @@ export function PMWizard({ boardsMutation={boardsMutation} boardDetailsMutation={boardDetailsMutation} /> + ) : state.provider === 'linear' ? ( + ) : ( + ) : state.provider === 'linear' ? ( + ) : (