+ )
+}
diff --git a/packages/ui/src/modules/app-store/app-permissions-dialog.tsx b/packages/ui/src/modules/app-store/app-permissions-dialog.tsx
deleted file mode 100644
index 4b969f7d32..0000000000
--- a/packages/ui/src/modules/app-store/app-permissions-dialog.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import {Close} from '@radix-ui/react-dialog'
-
-import {AppWithName} from '@/modules/app-store/shared'
-import {useApps} from '@/providers/apps'
-import {useAllAvailableApps} from '@/providers/available-apps'
-import {Button} from '@/shadcn-components/ui/button'
-import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/shadcn-components/ui/dialog'
-import {t} from '@/utils/i18n'
-
-export function AppPermissionsDialog({
- appId,
- open,
- onOpenChange,
- appsUsed,
- onNext,
-}: {
- appId: string
- open: boolean
- onOpenChange: (open: boolean) => void
- registryId?: string
- appsUsed: string[]
- onNext: () => void
-}) {
- const availableApps = useAllAvailableApps()
- const userApps = useApps()
- const app = availableApps.appsKeyed?.[appId]
-
- if (userApps.isLoading) return null
- if (availableApps.isLoading) return null
- if (!app) throw new Error('App not found')
-
- const appName = app?.name
- const appPermissions = appsUsed.map((id) => availableApps.appsKeyed?.[id])
-
- return (
-
- )
-}
diff --git a/packages/ui/src/modules/app-store/install-these-first-dialog.tsx b/packages/ui/src/modules/app-store/install-these-first-dialog.tsx
deleted file mode 100644
index 459b16c077..0000000000
--- a/packages/ui/src/modules/app-store/install-these-first-dialog.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import {Close} from '@radix-ui/react-dialog'
-import {TbCircleCheckFilled} from 'react-icons/tb'
-import {Link} from 'react-router-dom'
-import {arrayIncludes} from 'ts-extras'
-
-import {appStateToString} from '@/components/cmdk'
-import {AppWithName} from '@/modules/app-store/shared'
-import {useApps} from '@/providers/apps'
-import {useAllAvailableApps} from '@/providers/available-apps'
-import {Button} from '@/shadcn-components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/shadcn-components/ui/dialog'
-import {AppState, installedStates} from '@/trpc/trpc'
-import {t} from '@/utils/i18n'
-
-export function InstallTheseFirstDialog({
- open,
- onOpenChange,
- appId,
-
- dependencies,
-}: {
- open: boolean
- onOpenChange: (open: boolean) => void
- appId: string
-
- dependencies: string[]
-}) {
- const availableApps = useAllAvailableApps()
- const userApps = useApps()
- const app = availableApps.appsKeyed?.[appId]
-
- if (userApps.isLoading) return null
- if (availableApps.isLoading) return null
- if (!app) throw new Error('App not found')
-
- const appName = app?.name
- const allDepApps = dependencies.map((id) => availableApps.appsKeyed?.[id])
-
- return (
-
- )
-}
-
-function AppStateText({appId, appState, onClick}: {appId: string; appState: AppState; onClick?: () => void}) {
- if (arrayIncludes(installedStates, appState)) {
- return
- }
-
- switch (appState) {
- case 'not-installed':
- return (
- // TODO: link to community app store if needed using `getAppStoreAppFromInstalledApp`
-
- {t('app.install')}
-
- )
- default:
- return
{appStateToString(appState) + '...'}
- }
-}
diff --git a/packages/ui/src/modules/app-store/select-dependencies-dialog.tsx b/packages/ui/src/modules/app-store/select-dependencies-dialog.tsx
new file mode 100644
index 0000000000..5e36d27d1b
--- /dev/null
+++ b/packages/ui/src/modules/app-store/select-dependencies-dialog.tsx
@@ -0,0 +1,289 @@
+import {Close} from '@radix-ui/react-dialog'
+import {SetStateAction, useEffect, useState} from 'react'
+import {arrayIncludes} from 'ts-extras'
+
+import {ChevronDown} from '@/assets/chevron-down'
+import {AppIcon} from '@/components/app-icon'
+import {appStateToString} from '@/components/cmdk'
+import {ButtonLink} from '@/components/ui/button-link'
+import {useApps} from '@/providers/apps'
+import {useAllAvailableApps} from '@/providers/available-apps'
+import {Button} from '@/shadcn-components/ui/button'
+import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/shadcn-components/ui/dialog'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from '@/shadcn-components/ui/dropdown-menu'
+import {ScrollArea} from '@/shadcn-components/ui/scroll-area'
+import {cn} from '@/shadcn-lib/utils'
+import {AppState, installedStates, RegistryApp} from '@/trpc/trpc'
+import {t} from '@/utils/i18n'
+import {tw} from '@/utils/tw'
+
+export function SelectDependenciesDialog({
+ open,
+ onOpenChange,
+ appId,
+ dependencies,
+ onNext,
+ highlightDependency,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ appId: string
+ dependencies: {dependencyId: string; appId: string}[][]
+ onNext: (selectedDeps: Record) => void
+ highlightDependency?: string
+}) {
+ const availableApps = useAllAvailableApps()
+ const {isLoading, userApps, userAppsKeyed} = useApps()
+ const [selectedDependencies, setSelectedDependencies] = useState>({})
+
+ // Try user app first in case the app was installed at some point but is not
+ // present in an app store anymore, for example because a community app store
+ // has been removed. UserApp and RegistryApp share the necessary properties.
+ const registryApp = availableApps.appsKeyed?.[appId]
+ const userApp = userAppsKeyed?.[appId]
+ const app = userApp ?? registryApp
+ if (!app) throw new Error('App not found')
+
+ if (isLoading || !userApps || !userAppsKeyed || availableApps.isLoading) return null
+
+ const appName = app?.name
+
+ const areAllDependenciesInstalled = dependencies.every((alternatives) =>
+ alternatives.some((alternative) =>
+ Object.values(userAppsKeyed).some(
+ (installedApp) =>
+ installedApp.id === selectedDependencies[alternative.dependencyId] &&
+ arrayIncludes(installedStates, installedApp.state),
+ ),
+ ),
+ )
+
+ return (
+
+ )
+}
+
+// Reusable dependencies selection
+export function SelectDependencies({
+ dependencies,
+ selectedDependencies,
+ setSelectedDependencies,
+ onInstallClick,
+ highlightDependency,
+}: {
+ dependencies: {dependencyId: string; appId: string}[][]
+ selectedDependencies: Record
+ setSelectedDependencies: (selectedDependencies: Record) => void
+ onInstallClick: () => void
+ highlightDependency?: string
+}) {
+ const {apps, appsKeyed} = useAllAvailableApps()
+ const {isLoading, userApps, userAppsKeyed} = useApps()
+ const [openDropdowns, setOpenDropdowns] = useState>({})
+
+ if (isLoading || !userApps || !userAppsKeyed || !apps || !appsKeyed) return null
+
+ const reifiedDependencies = dependencies.map((alternatives) =>
+ alternatives.map(({dependencyId, appId}) => ({
+ dependencyId,
+ app: appsKeyed[appId],
+ })),
+ )
+
+ // Pre-select installed apps or main alternatives
+ useEffect(() => {
+ const newSelectedDependencies: Record = {
+ ...selectedDependencies,
+ }
+ reifiedDependencies.forEach((alternatives) => {
+ const dependencyId = alternatives[0].dependencyId
+ if (newSelectedDependencies[dependencyId]) return
+ const installedOrInstallingApp = alternatives.find(({app}) => {
+ const userApp = userAppsKeyed?.[app.id]
+ return userApp && (arrayIncludes(installedStates, userApp.state) || userApp.state === 'installing')
+ })
+ newSelectedDependencies[dependencyId] = installedOrInstallingApp
+ ? installedOrInstallingApp.app.id
+ : alternatives[0].app.id
+ })
+ setSelectedDependencies(newSelectedDependencies)
+ }, [dependencies])
+
+ const selectDependency = (dependencyId: string, appId: string) => {
+ const newSelectedDependencies = {
+ ...selectedDependencies,
+ [dependencyId]: appId,
+ }
+ setSelectedDependencies(newSelectedDependencies)
+ }
+
+ return (
+
+ {reifiedDependencies.map((alternatives) => {
+ const {dependencyId, app} = alternatives[0]
+ const hasAlternatives = alternatives.length > 1
+
+ if (!hasAlternatives) {
+ // If no alternatives, just show the app name and state
+ return (
+
+
+ {app.icon && }
+ {app.name}
+
+
+
+ )
+ }
+
+ // If has alternatives, show dropdown
+ return (
+