+
+ {{ $t('install.locationPicker.title') }}
-
- {{ $t('install.installLocationDescription') }}
+
+ {{ $t('install.locationPicker.subtitle') }}
-
-
-
-
-
-
+
+
+
+
-
- {{ pathError }}
-
-
- {{ $t('install.pathExists') }}
-
-
- {{ $t('install.nonDefaultDrive') }}
-
-
-
-
-
-
- {{ $t('install.systemLocations') }}
-
-
-
-
- App Data:
- {{ appData }}
-
-
-
-
- App Path:
- {{ appPath }}
-
-
+
+
+
+ {{ pathError }}
+
+
+ {{ $t('install.pathExists') }}
+
+
+ {{ $t('install.nonDefaultDrive') }}
+
+
+
+
+
+
+ {{ $t('install.locationPicker.migrateFromExisting') }}
+
+
+
+
+
+
+
+
+ {{ $t('install.locationPicker.chooseDownloadServers') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/install/MigrationPicker.stories.ts b/src/components/install/MigrationPicker.stories.ts
new file mode 100644
index 0000000000..ad09e1871b
--- /dev/null
+++ b/src/components/install/MigrationPicker.stories.ts
@@ -0,0 +1,45 @@
+// eslint-disable-next-line storybook/no-renderer-packages
+import type { Meta, StoryObj } from '@storybook/vue3'
+import { ref } from 'vue'
+
+import MigrationPicker from './MigrationPicker.vue'
+
+const meta: Meta
= {
+ title: 'Desktop/Components/MigrationPicker',
+ component: MigrationPicker,
+ parameters: {
+ backgrounds: {
+ default: 'dark',
+ values: [
+ { name: 'dark', value: '#0a0a0a' },
+ { name: 'neutral-900', value: '#171717' }
+ ]
+ }
+ },
+ decorators: [
+ () => {
+ ;(window as any).electronAPI = {
+ validateComfyUISource: () => Promise.resolve({ isValid: true }),
+ showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
+ }
+
+ return { template: '' }
+ }
+ ]
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ render: () => ({
+ components: { MigrationPicker },
+ setup() {
+ const sourcePath = ref('')
+ const migrationItemIds = ref([])
+ return { sourcePath, migrationItemIds }
+ },
+ template:
+ ''
+ })
+}
diff --git a/src/components/install/MigrationPicker.vue b/src/components/install/MigrationPicker.vue
index 934ffc2f32..ba542ca697 100644
--- a/src/components/install/MigrationPicker.vue
+++ b/src/components/install/MigrationPicker.vue
@@ -2,10 +2,6 @@
-
- {{ $t('install.migrateFromExistingInstallation') }}
-
-
{{ $t('install.migrationSourcePathDescription') }}
@@ -13,7 +9,7 @@
-
+
{{ $t('install.selectItemsToMigrate') }}
diff --git a/src/components/install/MirrorsConfiguration.vue b/src/components/install/MirrorsConfiguration.vue
deleted file mode 100644
index 0053f973a4..0000000000
--- a/src/components/install/MirrorsConfiguration.vue
+++ /dev/null
@@ -1,121 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/install/mirror/MirrorItem.vue b/src/components/install/mirror/MirrorItem.vue
index 3bd83074ef..f7660c2f32 100644
--- a/src/components/install/mirror/MirrorItem.vue
+++ b/src/components/install/mirror/MirrorItem.vue
@@ -1,10 +1,10 @@
-
-
-
+
+
+
{{ $t(`settings.${normalizedSettingId}.name`) }}
-
+
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
@@ -16,18 +16,61 @@
"
@state-change="validationState = $event"
/>
+
+
+ {{ $t('g.learnMore') }}
+
+
+
+
diff --git a/src/views/InstallView.stories.ts b/src/views/InstallView.stories.ts
new file mode 100644
index 0000000000..9a5c65e799
--- /dev/null
+++ b/src/views/InstallView.stories.ts
@@ -0,0 +1,423 @@
+// eslint-disable-next-line storybook/no-renderer-packages
+import type { Meta, StoryObj } from '@storybook/vue3'
+import { nextTick, provide } from 'vue'
+import { createMemoryHistory, createRouter } from 'vue-router'
+
+import InstallView from './InstallView.vue'
+
+// Create a mock router for stories
+const createMockRouter = () =>
+ createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: { template: '
Home
' } },
+ {
+ path: '/server-start',
+ component: { template: 'Server Start
' }
+ },
+ {
+ path: '/manual-configuration',
+ component: { template: 'Manual Configuration
' }
+ }
+ ]
+ })
+
+const meta: Meta = {
+ title: 'Desktop/Views/InstallView',
+ component: InstallView,
+ parameters: {
+ layout: 'fullscreen',
+ backgrounds: {
+ default: 'dark',
+ values: [
+ { name: 'dark', value: '#0a0a0a' },
+ { name: 'neutral-900', value: '#171717' },
+ { name: 'neutral-950', value: '#0a0a0a' }
+ ]
+ }
+ },
+ decorators: [
+ (story) => {
+ // Create router for this story
+ const router = createMockRouter()
+
+ // Mock electron API
+ ;(window as any).electronAPI = {
+ getPlatform: () => 'darwin',
+ Config: {
+ getDetectedGpu: () => Promise.resolve('mps')
+ },
+ Events: {
+ trackEvent: (eventName: string, data?: any) => {
+ console.log('Track event:', eventName, data)
+ }
+ },
+ installComfyUI: (options: any) => {
+ console.log('Install ComfyUI with options:', options)
+ },
+ changeTheme: (theme: any) => {
+ console.log('Change theme:', theme)
+ },
+ getSystemPaths: () =>
+ Promise.resolve({
+ defaultInstallPath: '/Users/username/ComfyUI'
+ }),
+ validateInstallPath: () =>
+ Promise.resolve({
+ isValid: true,
+ exists: false,
+ canWrite: true,
+ freeSpace: 100000000000,
+ requiredSpace: 10000000000,
+ isNonDefaultDrive: false
+ }),
+ validateComfyUISource: () =>
+ Promise.resolve({
+ isValid: true
+ }),
+ showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
+ }
+
+ return {
+ setup() {
+ // Provide router for all child components
+ provide('router', router)
+ return {
+ story
+ }
+ },
+ template: '
'
+ }
+ }
+ ]
+}
+
+export default meta
+type Story = StoryObj
+
+// Default story - start at GPU selection
+export const GpuSelection: Story = {
+ render: () => ({
+ components: { InstallView },
+ setup() {
+ // The component will automatically start at step 1
+ return {}
+ },
+ template: ''
+ })
+}
+
+// Story showing the install location step
+export const InstallLocation: Story = {
+ render: () => ({
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ async mounted() {
+ // Wait for component to be fully mounted
+ await nextTick()
+
+ // Select Apple Metal option to enable navigation
+ const hardwareOptions = this.$el.querySelectorAll(
+ '.p-selectbutton-option'
+ )
+ if (hardwareOptions.length > 0) {
+ hardwareOptions[0].click() // Click Apple Metal (first option)
+ }
+
+ await nextTick()
+
+ // Click Next to go to step 2
+ const buttons = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn) {
+ nextBtn.click()
+ }
+ },
+ template: ''
+ })
+}
+
+// Story showing the migration step (currently empty)
+export const MigrationStep: Story = {
+ render: () => ({
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ async mounted() {
+ // Wait for component to be fully mounted
+ await nextTick()
+
+ // Select Apple Metal option to enable navigation
+ const hardwareOptions = this.$el.querySelectorAll(
+ '.p-selectbutton-option'
+ )
+ if (hardwareOptions.length > 0) {
+ hardwareOptions[0].click() // Click Apple Metal (first option)
+ }
+
+ await nextTick()
+
+ // Click Next to go to step 2
+ const buttons1 = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn1 = buttons1.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn1) {
+ nextBtn1.click()
+ }
+
+ await nextTick()
+
+ // Click Next again to go to step 3
+ const buttons2 = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn2 = buttons2.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn2) {
+ nextBtn2.click()
+ }
+ },
+ template: ''
+ })
+}
+
+// Story showing the desktop settings configuration
+export const DesktopSettings: Story = {
+ render: () => ({
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ async mounted() {
+ // Wait for component to be fully mounted
+ await nextTick()
+
+ // Select Apple Metal option to enable navigation
+ const hardwareOptions = this.$el.querySelectorAll(
+ '.p-selectbutton-option'
+ )
+ if (hardwareOptions.length > 0) {
+ hardwareOptions[0].click() // Click Apple Metal (first option)
+ }
+
+ await nextTick()
+
+ // Click Next to go to step 2
+ const buttons1 = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn1 = buttons1.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn1) {
+ nextBtn1.click()
+ }
+
+ await nextTick()
+
+ // Click Next again to go to step 3
+ const buttons2 = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn2 = buttons2.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn2) {
+ nextBtn2.click()
+ }
+
+ await nextTick()
+
+ // Click Next again to go to step 4
+ const buttons3 = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn3 = buttons3.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn3) {
+ nextBtn3.click()
+ }
+ },
+ template: ''
+ })
+}
+
+// Story with Windows platform (no Apple Metal option)
+export const WindowsPlatform: Story = {
+ render: () => {
+ // Override the platform to Windows
+ ;(window as any).electronAPI.getPlatform = () => 'win32'
+ ;(window as any).electronAPI.Config.getDetectedGpu = () =>
+ Promise.resolve('nvidia')
+
+ return {
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ template: ''
+ }
+ }
+}
+
+// Story with macOS platform (Apple Metal option)
+export const MacOSPlatform: Story = {
+ name: 'macOS Platform',
+ render: () => {
+ // Override the platform to macOS
+ ;(window as any).electronAPI.getPlatform = () => 'darwin'
+ ;(window as any).electronAPI.Config.getDetectedGpu = () =>
+ Promise.resolve('mps')
+
+ return {
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ template: ''
+ }
+ }
+}
+
+// Story with CPU selected
+export const CpuSelected: Story = {
+ render: () => ({
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ async mounted() {
+ // Wait for component to be fully mounted
+ await nextTick()
+
+ // Find and click the CPU hardware option
+ const hardwareButtons = this.$el.querySelectorAll('.hardware-option')
+ // CPU is the button with "CPU" text
+ for (const button of hardwareButtons) {
+ if (button.textContent?.includes('CPU')) {
+ button.click()
+ break
+ }
+ }
+ },
+ template: ''
+ })
+}
+
+// Story with manual install selected
+export const ManualInstall: Story = {
+ render: () => ({
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ async mounted() {
+ // Wait for component to be fully mounted
+ await nextTick()
+
+ // Find and click the Manual Install hardware option
+ const hardwareButtons = this.$el.querySelectorAll('.hardware-option')
+ // Manual Install is the button with "Manual Install" text
+ for (const button of hardwareButtons) {
+ if (button.textContent?.includes('Manual Install')) {
+ button.click()
+ break
+ }
+ }
+ },
+ template: ''
+ })
+}
+
+// Story with error state (invalid install path)
+export const ErrorState: Story = {
+ render: () => {
+ // Override validation to return an error
+ ;(window as any).electronAPI.validateInstallPath = () =>
+ Promise.resolve({
+ isValid: false,
+ exists: false,
+ canWrite: false,
+ freeSpace: 100000000000,
+ requiredSpace: 10000000000,
+ isNonDefaultDrive: false,
+ error: 'Story mock: Example error state'
+ })
+
+ return {
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ async mounted() {
+ // Wait for component to be fully mounted
+ await nextTick()
+
+ // Select Apple Metal option to enable navigation
+ const hardwareOptions = this.$el.querySelectorAll(
+ '.p-selectbutton-option'
+ )
+ if (hardwareOptions.length > 0) {
+ hardwareOptions[0].click() // Click Apple Metal (first option)
+ }
+
+ await nextTick()
+
+ // Click Next to go to step 2 where error will be shown
+ const buttons = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn) {
+ nextBtn.click()
+ }
+ },
+ template: ''
+ }
+ }
+}
+
+// Story with warning state (non-default drive)
+export const WarningState: Story = {
+ render: () => {
+ // Override validation to return a warning about non-default drive
+ ;(window as any).electronAPI.validateInstallPath = () =>
+ Promise.resolve({
+ isValid: true,
+ exists: false,
+ canWrite: true,
+ freeSpace: 500_000_000_000,
+ requiredSpace: 10_000_000_000,
+ isNonDefaultDrive: true
+ })
+
+ return {
+ components: { InstallView },
+ setup() {
+ return {}
+ },
+ async mounted() {
+ // Wait for component to be fully mounted
+ await nextTick()
+
+ // Select Apple Metal option to enable navigation
+ const hardwareOptions = this.$el.querySelectorAll('.hardware-option')
+ if (hardwareOptions.length > 0) {
+ hardwareOptions[0].click() // Click Apple Metal (first option)
+ }
+
+ await nextTick()
+
+ // Click Next to go to step 2 where warning will be shown
+ const buttons = Array.from(
+ this.$el.querySelectorAll('button')
+ ) as HTMLButtonElement[]
+ const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
+ if (nextBtn) {
+ nextBtn.click()
+ }
+ },
+ template: ''
+ }
+ }
+}
diff --git a/src/views/InstallView.vue b/src/views/InstallView.vue
index ed3da0a8ac..6b170937cb 100644
--- a/src/views/InstallView.vue
+++ b/src/views/InstallView.vue
@@ -1,111 +1,54 @@
-
-
-
-
- {{ $t('install.gpu') }}
-
-
- {{ $t('install.installLocation') }}
-
-
- {{ $t('install.migration') }}
-
-
- {{ $t('install.desktopSettings') }}
-
-
-
-
-
-
-
@@ -114,9 +57,6 @@ import type {
InstallOptions,
TorchDeviceType
} from '@comfyorg/comfyui-electron-types'
-import Button from 'primevue/button'
-import Step from 'primevue/step'
-import StepList from 'primevue/steplist'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
@@ -125,9 +65,8 @@ import { useRouter } from 'vue-router'
import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsConfiguration.vue'
import GpuPicker from '@/components/install/GpuPicker.vue'
+import InstallFooter from '@/components/install/InstallFooter.vue'
import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue'
-import MigrationPicker from '@/components/install/MigrationPicker.vue'
-import MirrorsConfiguration from '@/components/install/MirrorsConfiguration.vue'
import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
@@ -145,6 +84,9 @@ const pythonMirror = ref('')
const pypiMirror = ref('')
const torchMirror = ref('')
+/** Current step in the stepper */
+const currentStep = ref('1')
+
/** Forces each install step to be visited at least once. */
const highestStep = ref(0)
@@ -164,6 +106,40 @@ const setHighestStep = (value: string | number) => {
const hasError = computed(() => pathError.value !== '')
const noGpu = computed(() => typeof device.value !== 'string')
+// Computed property to determine if user can proceed to next step
+const regex = /^Insufficient space - minimum free space: \d+ GB$/
+
+const canProceed = computed(() => {
+ switch (currentStep.value) {
+ case '1':
+ return typeof device.value === 'string'
+ case '2':
+ return pathError.value === '' || regex.test(pathError.value)
+ case '3':
+ return !hasError.value
+ default:
+ return false
+ }
+})
+
+// Navigation methods
+const goToNextStep = () => {
+ const nextStep = (parseInt(currentStep.value) + 1).toString()
+ currentStep.value = nextStep
+ setHighestStep(nextStep)
+ electronAPI().Events.trackEvent('install_stepper_change', {
+ step: nextStep
+ })
+}
+
+const goToPreviousStep = () => {
+ const prevStep = (parseInt(currentStep.value) - 1).toString()
+ currentStep.value = prevStep
+ electronAPI().Events.trackEvent('install_stepper_change', {
+ step: prevStep
+ })
+}
+
const electron = electronAPI()
const router = useRouter()
const install = async () => {
@@ -195,7 +171,7 @@ onMounted(async () => {
}
electronAPI().Events.trackEvent('install_stepper_change', {
- step: '0',
+ step: currentStep.value,
gpu: detectedGpu
})
})
@@ -205,6 +181,30 @@ onMounted(async () => {
@reference '../assets/css/style.css';
:deep(.p-steppanel) {
+ @apply mt-8 flex justify-center bg-transparent;
+}
+
+/* Remove default padding/margin from StepPanels to make scrollbar flush */
+:deep(.p-steppanels) {
+ @apply p-0 m-0;
+}
+
+/* Ensure StepPanel content container has no top/bottom padding */
+:deep(.p-steppanel-content) {
+ @apply p-0;
+}
+
+/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
+:deep(.p-steppanels::-webkit-scrollbar) {
+ @apply w-4;
+}
+
+:deep(.p-steppanels::-webkit-scrollbar-track) {
@apply bg-transparent;
}
+
+:deep(.p-steppanels::-webkit-scrollbar-thumb) {
+ @apply bg-white/20 rounded-lg border-[4px] border-transparent;
+ background-clip: content-box;
+}
diff --git a/src/views/ServerStartView.vue b/src/views/ServerStartView.vue
index dff101b583..f84a41a710 100644
--- a/src/views/ServerStartView.vue
+++ b/src/views/ServerStartView.vue
@@ -1,75 +1,204 @@
-
-
-
- {{ t(`serverStart.process.${status}`) }}
-
- v{{ electronVersion }}
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('serverStart.showTerminal') }}
+
+
+
+
+
+
-
+
+
diff --git a/src/views/WelcomeView.vue b/src/views/WelcomeView.vue
index 597bc5a6b8..ad653e6a8e 100644
--- a/src/views/WelcomeView.vue
+++ b/src/views/WelcomeView.vue
@@ -1,21 +1,27 @@
-
-
-
- {{ $t('welcome.title') }}
-
-
-
-
+
+
+
+
+

+
+
+
+
+
+
@@ -31,49 +37,3 @@ const navigateTo = async (path: string) => {
await router.push(path)
}
-
-