From 8dcf12fd28b610612171c23f29254219a35b9b47 Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 09:23:02 +0530 Subject: [PATCH 01/84] style(chooser): nudge brand beam anchor for chooser background Centre the layered beam against the responsive chooser spotlight and align BrandBackground defaults formatting with Vue style. --- src/renderer/src/components/BrandBackground.vue | 2 +- src/renderer/src/views/ChooserView.vue | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/BrandBackground.vue b/src/renderer/src/components/BrandBackground.vue index bbf0d5c2..f48ebc1e 100644 --- a/src/renderer/src/components/BrandBackground.vue +++ b/src/renderer/src/components/BrandBackground.vue @@ -17,7 +17,7 @@ withDefaults( defineProps<{ vignette?: boolean }>(), - { vignette: false }, + { vignette: false } ) diff --git a/src/renderer/src/views/ChooserView.vue b/src/renderer/src/views/ChooserView.vue index a3c19942..7f8e71d5 100644 --- a/src/renderer/src/views/ChooserView.vue +++ b/src/renderer/src/views/ChooserView.vue @@ -269,6 +269,10 @@ function handleNewInstallClick(): void { background: transparent; } +.chooser-bg :deep(.brand-beam--2) { + left: anchor(center, clamp(39%, calc(52.5vw - 135px), 44%)); +} + .chooser-view { /* Fluid centering pattern with a top-spacer floor: * - Short grid → both spacers grow toward 1fr → cluster centered From 2eb8562699545d6c81e5fa460c725d746a3d3985 Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 15:16:13 +0530 Subject: [PATCH 02/84] style(modal): blur BaseModal overlay backdrop Add frosted backdrop on the shared shell so layered modals read against host UI instead of a flat wash. --- src/renderer/src/components/ui/BaseModal.vue | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/ui/BaseModal.vue b/src/renderer/src/components/ui/BaseModal.vue index 91035b6e..0f36093f 100644 --- a/src/renderer/src/components/ui/BaseModal.vue +++ b/src/renderer/src/components/ui/BaseModal.vue @@ -14,9 +14,9 @@ import { useModalOverlay } from '../../composables/useModalOverlay' * accessible name. We warn loudly in dev rather than throwing so a * mis-wired modal still renders in prod. * - * Sits at z-index 60 to match `TermsModal` / `WhyTryCloudModal` (below - * context menus, above the settings drawer). Migration of those modals - * onto this primitive is a follow-up (TODO(modal-migration)). + * Sits at z-index 60 to match `WhyTryCloudModal` (below context menus, + * above the settings drawer). Migration of `WhyTryCloudModal` onto + * this primitive is a follow-up (TODO(modal-migration)). */ type Size = 'sm' | 'md' | 'lg' | 'xl' @@ -189,6 +189,8 @@ const sizeClass = computed(() => `is-size-${props.size}`) place-items: center; padding: clamp(32px, 6vh, 72px) clamp(16px, 4vw, 48px); background: color-mix(in oklab, var(--neutral-800) 70%, transparent); + backdrop-filter: blur(12px) saturate(120%); + -webkit-backdrop-filter: blur(12px) saturate(120%); } .base-modal-panel { From a2c10d3cd7a67e10a6e67efdc0325fe3d6d1a3e6 Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 15:16:30 +0530 Subject: [PATCH 03/84] refactor(terms): render legal modal on BaseModal Drop bespoke overlay wiring and reuse the shared close + focus + backdrop behaviour; update the test to target the primitive close. --- .../src/components/TermsModal.test.ts | 3 +- src/renderer/src/components/TermsModal.vue | 186 +++++------------- 2 files changed, 49 insertions(+), 140 deletions(-) diff --git a/src/renderer/src/components/TermsModal.test.ts b/src/renderer/src/components/TermsModal.test.ts index bab7ec4e..e3523c97 100644 --- a/src/renderer/src/components/TermsModal.test.ts +++ b/src/renderer/src/components/TermsModal.test.ts @@ -70,7 +70,8 @@ describe('TermsModal', () => { it('emits `close` when the ✕ button is clicked', async () => { const wrapper = mountModal('eula') - await wrapper.find('[data-testid="terms-modal-close"]').trigger('click') + // ✕ now lives on the BaseModal primitive. + await wrapper.find('[data-testid="base-modal-close"]').trigger('click') expect(wrapper.emitted('close')).toHaveLength(1) }) diff --git a/src/renderer/src/components/TermsModal.vue b/src/renderer/src/components/TermsModal.vue index 14bf4ffc..c60f34a7 100644 --- a/src/renderer/src/components/TermsModal.vue +++ b/src/renderer/src/components/TermsModal.vue @@ -1,6 +1,7 @@ + + From 19449288b44fcc16a8b17a91902e6ec53c8ba43f Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 16:31:47 +0530 Subject: [PATCH 15/84] refactor(tooltip): forward TooltipWrap to ui Tooltip Preserve legacy import paths while migrating behaviour to the shared primitive (side + align forwarded). --- src/renderer/src/components/TooltipWrap.vue | 54 ++++++++------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/TooltipWrap.vue b/src/renderer/src/components/TooltipWrap.vue index 293b3b2a..e8049acb 100644 --- a/src/renderer/src/components/TooltipWrap.vue +++ b/src/renderer/src/components/TooltipWrap.vue @@ -1,45 +1,31 @@ - - From 2e91bb86284c078ceead9c46b5cbb442044e0cd0 Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 16:32:18 +0530 Subject: [PATCH 16/84] refactor(info-tooltip): compose shared Tooltip primitive Defer placement and bubble chrome to `/ui`; add focus-visible icon lift and side/delay forwarding. --- .../src/components/InfoTooltip.test.ts | 59 ++++++++-------- src/renderer/src/components/InfoTooltip.vue | 67 ++++++------------- 2 files changed, 51 insertions(+), 75 deletions(-) diff --git a/src/renderer/src/components/InfoTooltip.test.ts b/src/renderer/src/components/InfoTooltip.test.ts index cf6cb3b5..8d358ad5 100644 --- a/src/renderer/src/components/InfoTooltip.test.ts +++ b/src/renderer/src/components/InfoTooltip.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect } from 'vitest' -import { mount } from '@vue/test-utils' +import { mount, flushPromises } from '@vue/test-utils' import InfoTooltip from './InfoTooltip.vue' +/** + * `InfoTooltip` composes the shared `Tooltip` primitive around the + * lucide `CircleHelp` icon. These tests pin the trigger surface + + * forwarding behavior — primitive-level placement / flip / arrow + * concerns belong in `ui/Tooltip.test.ts`. + */ describe('InfoTooltip', () => { it('renders the icon', () => { const wrapper = mount(InfoTooltip, { props: { text: 'tip' } }) @@ -9,55 +15,52 @@ describe('InfoTooltip', () => { }) it('does not show bubble initially', () => { - const wrapper = mount(InfoTooltip, { props: { text: 'Hello tooltip' } }) - expect(wrapper.find('.info-tooltip-bubble').exists()).toBe(false) - }) - - it('shows bubble with correct text on mouseenter', async () => { const wrapper = mount(InfoTooltip, { props: { text: 'Hello tooltip' }, attachTo: document.body, }) - await wrapper.find('.info-tooltip-trigger').trigger('mouseenter') - const bubble = document.querySelector('.info-tooltip-bubble') - expect(bubble).not.toBeNull() - expect(bubble!.textContent).toBe('Hello tooltip') + expect(document.querySelector('.tooltip-bubble')).toBeNull() wrapper.unmount() }) - it('hides bubble on mouseleave', async () => { + it('shows bubble with correct text on mouseenter', async () => { const wrapper = mount(InfoTooltip, { - props: { text: 'tip' }, + props: { text: 'Hello tooltip', delayMs: 0 } as Record, attachTo: document.body, }) - await wrapper.find('.info-tooltip-trigger').trigger('mouseenter') - expect(document.querySelector('.info-tooltip-bubble')).not.toBeNull() - await wrapper.find('.info-tooltip-trigger').trigger('mouseleave') - expect(document.querySelector('.info-tooltip-bubble')).toBeNull() + await wrapper.find('.tooltip-wrap').trigger('mouseenter') + await flushPromises() + const bubble = document.querySelector('.tooltip-bubble') + expect(bubble).not.toBeNull() + expect(bubble!.textContent?.trim()).toContain('Hello tooltip') wrapper.unmount() }) - it('defaults to top side positioning', async () => { + it('hides bubble on mouseleave', async () => { const wrapper = mount(InfoTooltip, { - props: { text: 'tip' }, + props: { text: 'tip', delayMs: 0 } as Record, attachTo: document.body, }) - await wrapper.find('.info-tooltip-trigger').trigger('mouseenter') - const bubble = document.querySelector('.info-tooltip-bubble') as HTMLElement - expect(bubble.style.bottom).toBeTruthy() - expect(bubble.style.top).toBeFalsy() + await wrapper.find('.tooltip-wrap').trigger('mouseenter') + await flushPromises() + expect(document.querySelector('.tooltip-bubble')).not.toBeNull() + await wrapper.find('.tooltip-wrap').trigger('mouseleave') + await flushPromises() + expect(document.querySelector('.tooltip-bubble')).toBeNull() wrapper.unmount() }) - it('uses top positioning for bottom side', async () => { + it('renders the bubble with a resolved `data-side` attribute', async () => { const wrapper = mount(InfoTooltip, { - props: { text: 'tip', side: 'bottom' }, + props: { text: 'tip', side: 'bottom', delayMs: 0 } as Record, attachTo: document.body, }) - await wrapper.find('.info-tooltip-trigger').trigger('mouseenter') - const bubble = document.querySelector('.info-tooltip-bubble') as HTMLElement - expect(bubble.style.top).toBeTruthy() - expect(bubble.style.bottom).toBeFalsy() + await wrapper.find('.tooltip-wrap').trigger('mouseenter') + await flushPromises() + const bubble = document.querySelector('.tooltip-bubble') + // jsdom returns zero-sized rects, so the placement resolver may flip + // to whichever axis has more room — assert one of the valid sides. + expect(bubble?.getAttribute('data-side')).toMatch(/^(top|bottom|left|right)$/) wrapper.unmount() }) }) diff --git a/src/renderer/src/components/InfoTooltip.vue b/src/renderer/src/components/InfoTooltip.vue index ac98b064..9b40102a 100644 --- a/src/renderer/src/components/InfoTooltip.vue +++ b/src/renderer/src/components/InfoTooltip.vue @@ -1,36 +1,29 @@ - - From 401547f28d7f3c3bd3ad2dbe93c65202049001cd Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 16:32:34 +0530 Subject: [PATCH 17/84] fix(progress): seed operation before progress overlay mounts Avoid an empty ProgressModal flash by starting show-progress IPC before Tier-3 takeover swap then wiring ProgressModal with showOperation only. --- src/renderer/src/panel/usePanelOverlays.ts | 40 ++++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/panel/usePanelOverlays.ts b/src/renderer/src/panel/usePanelOverlays.ts index 77b5bd0a..de474359 100644 --- a/src/renderer/src/panel/usePanelOverlays.ts +++ b/src/renderer/src/panel/usePanelOverlays.ts @@ -222,6 +222,27 @@ export function usePanelOverlays(opts: UsePanelOverlaysOpts): UsePanelOverlaysAp const onCancel = (): void => { progressStore.cancelOperation(showOpts.installationId) } + // Pre-seed the operation in the progress store BEFORE swapping the + // overlay so `ProgressModal` paints fully populated on its first + // frame (op title, "Starting…" status, brand loader). Without this + // the modal mounts on a still-empty operation map and renders a + // blank/placeholder frame for one tick — the visible flicker users + // saw between Continue and the loader. The IPC `apiCall` fires + // synchronously from `progressStore.startOperation`, so the install + // begins a tick earlier than before; that's fine since the loader + // appears in the same swap. + const existing = progressStore.operations.get(showOpts.installationId) + if (!existing || existing.finished) { + progressStore.startOperation({ + installationId: showOpts.installationId, + title: showOpts.title, + apiCall: showOpts.apiCall as () => Promise, + cancellable: showOpts.cancellable, + returnTo: showOpts.returnTo, + opKind: showOpts.opKind, + destroysInstance: showOpts.destroysInstance, + }) + } // Every show-progress op renders as a Tier 3 brand takeover now — // delete, copy, migrate, install, update, launch, and snapshot ops // share the same loader chrome (BrandTakeoverLayout + glyph + @@ -256,21 +277,10 @@ export function usePanelOverlays(opts: UsePanelOverlaysOpts): UsePanelOverlaysAp ) } await nextTick() - // If an in-progress operation already exists for this ID, just show it. - const existing = progressStore.operations.get(showOpts.installationId) - if (existing && !existing.finished) { - progressRef.value?.showOperation(showOpts.installationId) - return - } - progressRef.value?.startOperation({ - installationId: showOpts.installationId, - title: showOpts.title, - apiCall: showOpts.apiCall as () => Promise, - cancellable: showOpts.cancellable, - returnTo: showOpts.returnTo, - opKind: showOpts.opKind, - destroysInstance: showOpts.destroysInstance, - }) + // After the overlay swap, ProgressModal exists — point it at the + // (already running) operation so its internal `currentId` ref tracks + // it for showOperation/auto-close logic. + progressRef.value?.showOperation(showOpts.installationId) } function handleProgressClose(): void { From 8cbcb6ce1207b90109e5b6e13b0a6f40766b6648 Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 16:32:50 +0530 Subject: [PATCH 18/84] feat(first-use-chain): standalone express install shortcut Skip Configure using recommended standalone field picks when chain-local receives express, with graceful fallback plus coverage for the IPC path. --- .../src/panel/useFirstUseChain.test.ts | 227 ++++++++++++++++++ src/renderer/src/panel/useFirstUseChain.ts | 117 ++++++++- 2 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/panel/useFirstUseChain.test.ts diff --git a/src/renderer/src/panel/useFirstUseChain.test.ts b/src/renderer/src/panel/useFirstUseChain.test.ts new file mode 100644 index 00000000..7192f282 --- /dev/null +++ b/src/renderer/src/panel/useFirstUseChain.test.ts @@ -0,0 +1,227 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref, type Ref } from 'vue' +import { mount } from '@vue/test-utils' +import type { FieldOption, Source } from '../types/ipc' +import { useFirstUseChain, type FirstUseChainApi } from './useFirstUseChain' + +/** + * `useFirstUseChain` integration tests focused on the Express Install + * path. Express skips the Configure (`InstallWizardModal`) screen and + * runs Standalone + recommended defaults straight through to the + * install-progress takeover. The other chain branches (Cloud auto- + * launch, migrate, file-menu skip) are exercised indirectly by + * PanelApp's integration tests. + */ + +const standaloneSource: Source = { + id: 'standalone', + label: 'Standalone', + fields: [ + { id: 'release', label: 'Release', type: 'select' }, + { id: 'variant', label: 'Variant', type: 'select' }, + ], +} + +const recommendedRelease: FieldOption = { + value: 'v1.0.0', + label: 'v1.0.0', + recommended: true, +} +const fallbackRelease: FieldOption = { value: 'v0.9.0', label: 'v0.9.0' } +const recommendedVariant: FieldOption = { + value: 'nvidia', + label: 'NVIDIA', + recommended: true, + data: { variantId: 'nvidia-cuda' }, +} + +interface TestApi { + validateHardware: ReturnType + getDefaultInstallDir: ReturnType + getSources: ReturnType + getFieldOptions: ReturnType + buildInstallation: ReturnType + getUniqueName: ReturnType + addInstallation: ReturnType + installInstance: ReturnType + setFirstUseMode: ReturnType + setSetting: ReturnType + getSetting: ReturnType + getInstallations: ReturnType + onInstallationsChanged: ReturnType + onInstallationsVersionsUpdated: ReturnType + onFirstUseSkip: ReturnType + onErrorDetail: ReturnType + onInstallProgress: ReturnType + onComfyOutput: ReturnType +} + +function buildApi(overrides: Partial = {}): TestApi { + return { + validateHardware: vi.fn().mockResolvedValue({ supported: true }), + getDefaultInstallDir: vi.fn().mockResolvedValue('/Users/test/ComfyUI'), + getSources: vi.fn().mockResolvedValue([standaloneSource]), + getFieldOptions: vi.fn().mockImplementation((_sourceId: string, fieldId: string) => { + if (fieldId === 'release') return Promise.resolve([recommendedRelease, fallbackRelease]) + if (fieldId === 'variant') return Promise.resolve([recommendedVariant]) + return Promise.resolve([]) + }), + buildInstallation: vi.fn().mockResolvedValue({ sourceId: 'standalone', sourceCategory: 'local' }), + getUniqueName: vi.fn().mockResolvedValue('ComfyUI'), + addInstallation: vi.fn().mockResolvedValue({ ok: true, entry: { id: 'inst-express-1' } }), + installInstance: vi.fn().mockResolvedValue(undefined), + setFirstUseMode: vi.fn(), + setSetting: vi.fn().mockResolvedValue(undefined), + getSetting: vi.fn().mockResolvedValue(undefined), + getInstallations: vi.fn().mockResolvedValue([]), + onInstallationsChanged: vi.fn(), + onInstallationsVersionsUpdated: vi.fn(), + onFirstUseSkip: vi.fn().mockReturnValue(() => {}), + onErrorDetail: vi.fn().mockReturnValue(() => {}), + onInstallProgress: vi.fn().mockReturnValue(() => {}), + onComfyOutput: vi.fn().mockReturnValue(() => {}), + ...overrides, + } +} + +interface MountedChain { + api: FirstUseChainApi | null + handleShowProgress: ReturnType + switchPanel: ReturnType + dismissTakeoverDirect: ReturnType + performChooserLaunch: ReturnType + openFirstUseTakeover: ReturnType +} + +function mountChain(): MountedChain { + const handleShowProgress = vi.fn().mockResolvedValue(undefined) + const switchPanel = vi.fn().mockResolvedValue(undefined) + const dismissTakeoverDirect = vi.fn() + const performChooserLaunch = vi.fn().mockResolvedValue('launched') + const openFirstUseTakeover = vi.fn().mockResolvedValue(undefined) + + const apiRef: Ref = ref(null) + + const TestHost = defineComponent({ + setup() { + apiRef.value = useFirstUseChain({ + handleShowProgress, + switchPanel, + dismissTakeoverDirect, + performChooserLaunch, + openFirstUseTakeover, + }) + return () => h('div') + }, + }) + + mount(TestHost) + + return { + api: apiRef.value, + handleShowProgress, + switchPanel, + dismissTakeoverDirect, + performChooserLaunch, + openFirstUseTakeover, + } +} + +describe('useFirstUseChain — Express Install', () => { + let testApi: TestApi + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + testApi = buildApi() + vi.stubGlobal('window', { ...window, api: testApi }) + vi.clearAllMocks() + }) + + it('skips Configure when `express: true` — runs buildInstallation/addInstallation/handleShowProgress', async () => { + const chain = mountChain() + await chain.api!.handleFirstUseChainLocal({ express: true }) + + expect(testApi.validateHardware).toHaveBeenCalledTimes(1) + expect(testApi.getSources).toHaveBeenCalledTimes(1) + expect(testApi.buildInstallation).toHaveBeenCalledWith( + 'standalone', + expect.objectContaining({ + release: recommendedRelease, + variant: recommendedVariant, + }), + ) + expect(testApi.addInstallation).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'ComfyUI', + installPath: '/Users/test/ComfyUI', + sourceId: 'standalone', + }), + ) + expect(chain.handleShowProgress).toHaveBeenCalledWith( + expect.objectContaining({ + installationId: 'inst-express-1', + autoLaunchOnFinish: true, + opKind: 'install', + }), + ) + // Did NOT delegate to Configure when express succeeded. + expect(chain.switchPanel).not.toHaveBeenCalled() + }) + + it('picks the `recommended` option for each non-text field', async () => { + const chain = mountChain() + await chain.api!.handleFirstUseChainLocal({ express: true }) + + const call = testApi.buildInstallation.mock.calls[0] + expect(call).toBeDefined() + const selections = call![1] as Record + expect(selections.release).toEqual(recommendedRelease) + expect(selections.variant).toEqual(recommendedVariant) + }) + + it('falls back to Configure when hardware validation reports unsupported', async () => { + testApi.validateHardware = vi + .fn() + .mockResolvedValue({ supported: false, error: 'No GPU' }) + vi.stubGlobal('window', { ...window, api: testApi }) + + const chain = mountChain() + await chain.api!.handleFirstUseChainLocal({ express: true }) + + expect(testApi.buildInstallation).not.toHaveBeenCalled() + expect(chain.handleShowProgress).not.toHaveBeenCalled() + expect(chain.switchPanel).toHaveBeenCalledWith('new-install', 'first_use') + }) + + it('falls back to Configure when addInstallation rejects', async () => { + testApi.addInstallation = vi + .fn() + .mockResolvedValue({ ok: false, message: 'path conflict' }) + vi.stubGlobal('window', { ...window, api: testApi }) + + const chain = mountChain() + await chain.api!.handleFirstUseChainLocal({ express: true }) + + expect(chain.handleShowProgress).not.toHaveBeenCalled() + expect(chain.switchPanel).toHaveBeenCalledWith('new-install', 'first_use') + }) + + it('opens Configure when `express` is omitted (legacy chain-local behaviour)', async () => { + const chain = mountChain() + await chain.api!.handleFirstUseChainLocal() + + expect(chain.switchPanel).toHaveBeenCalledWith('new-install', 'first_use') + expect(testApi.buildInstallation).not.toHaveBeenCalled() + expect(chain.handleShowProgress).not.toHaveBeenCalled() + }) + + it('opens Configure when `express: false`', async () => { + const chain = mountChain() + await chain.api!.handleFirstUseChainLocal({ express: false }) + + expect(chain.switchPanel).toHaveBeenCalledWith('new-install', 'first_use') + expect(testApi.buildInstallation).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/panel/useFirstUseChain.ts b/src/renderer/src/panel/useFirstUseChain.ts index aa10f444..05abf208 100644 --- a/src/renderer/src/panel/useFirstUseChain.ts +++ b/src/renderer/src/panel/useFirstUseChain.ts @@ -4,7 +4,7 @@ import { useProgressStore } from '../stores/progressStore' import { useLauncherPrefs } from '../composables/useLauncherPrefs' import { useMigrateAction } from '../composables/useMigrateAction' import { useOverlay } from '../composables/useOverlay' -import type { Installation, ShowProgressOpts } from '../types/ipc' +import type { FieldOption, Installation, ShowProgressOpts, Source } from '../types/ipc' import type { ChooserLaunchOutcome } from './useChooserHandoff' import type { FirstUseChainHooks, PanelKey } from './usePanelOverlays' @@ -44,9 +44,12 @@ export interface FirstUseChainApi { handleFirstUseComplete: () => Promise /** FirstUseTakeover `chain-local` emit. Optional payload flags * whether the chain reached us via the Local → Start Fresh - * sub-step (vs the direct no-legacy path). */ + * sub-step (vs the direct no-legacy path), and whether the user + * asked for an Express install (skip the Configure screen and run + * Standalone + recommended defaults straight through to the + * install-progress takeover). */ handleFirstUseChainLocal: ( - payload?: { cameFromLocalBranch?: boolean } + payload?: { cameFromLocalBranch?: boolean; express?: boolean } ) => Promise /** FirstUseTakeover `chain-migrate` emit. */ handleFirstUseChainMigrate: () => Promise @@ -232,13 +235,31 @@ export function useFirstUseChain(opts: FirstUseChainOpts): FirstUseChainApi { * Tier 3 takeover. The Tier 3 → Tier 3 swap is silent in * `useOverlay`, so the first-use takeover unmounts as the new-install * takeover mounts. The completion flip is deferred to the new-install - * close path (see `handleNewInstallTakeoverClose`). */ + * close path (see `handleNewInstallTakeoverClose`). + * + * When `payload.express === true`, skip the Configure screen entirely + * and run the same `buildInstallation → addInstallation → show-progress` + * sequence Configure's `handleSave` runs, using the recommended option + * for every non-text field on the Standalone source (the same defaults + * Configure pre-selects). If any step fails, fall back to opening + * Configure so the user sees the actual error rather than a silent + * dead end. */ async function handleFirstUseChainLocal( - payload?: { cameFromLocalBranch?: boolean }, + payload?: { cameFromLocalBranch?: boolean; express?: boolean }, ): Promise { chainingFirstUseToNewInstall.value = true pendingFirstUseAutoLaunchId.value = null pendingCameFromLocalBranch.value = payload?.cameFromLocalBranch === true + + if (payload?.express === true) { + const expressOk = await runExpressInstall() + if (expressOk) return + // Fall through to the Configure screen — runExpressInstall already + // reset chain bookkeeping on failure. + chainingFirstUseToNewInstall.value = true + pendingFirstUseAutoLaunchId.value = null + } + await opts.switchPanel('new-install', 'first_use') // FirstUseTakeover.onUnmounted just pushed `'none'` as the chain // swap unmounted it. Re-assert `'post-consent'` so the file-menu @@ -247,6 +268,92 @@ export function useFirstUseChain(opts: FirstUseChainOpts): FirstUseChainApi { window.api.setFirstUseMode('post-consent') } + /** Express install — the "skip Configure" path. Runs the Standalone + * source with the `recommended` option for every non-text field + * (mirroring Configure's `loadFieldOptions` default-selection logic), + * then hands off to the install-progress takeover via + * `handleShowProgress`. Returns `true` on success so the caller + * knows the chain handoff is complete; `false` if any precondition + * failed and the caller should fall back to opening Configure. */ + async function runExpressInstall(): Promise { + try { + const hardware = await window.api.validateHardware() + if (!hardware.supported) { + console.warn('[firstUseChain] express: hardware unsupported', hardware) + return false + } + + const [installDir, sources] = await Promise.all([ + window.api.getDefaultInstallDir().catch(() => ''), + window.api.getSources(), + ]) + const standalone = sources.find((s: Source) => s.id === 'standalone') + if (!standalone) { + console.warn('[firstUseChain] express: standalone source missing', { sources }) + return false + } + + const selections: Record = {} + for (const field of standalone.fields) { + if (field.type === 'text') { + if (field.defaultValue !== undefined) { + selections[field.id] = { value: field.defaultValue, label: field.defaultValue } + } + continue + } + const options = await window.api.getFieldOptions( + standalone.id, + field.id, + selections, + field.id === 'release' ? { includeLatestStable: true } : undefined, + ) + if (!options || options.length === 0) { + console.warn('[firstUseChain] express: no options for field', field.id) + return false + } + const pick = options.find((o) => o.recommended) ?? options[0] + if (!pick) return false + selections[field.id] = pick + } + + const instData = await window.api.buildInstallation(standalone.id, selections) + const name = await window.api.getUniqueName('ComfyUI') + const installPath = installDir ?? '' + + const result = await window.api.addInstallation({ + name, + installPath, + ...instData, + }) + if (!result.ok || !result.entry) { + console.warn('[firstUseChain] express: addInstallation failed', result) + return false + } + + // `onShowProgress` captures `pendingFirstUseAutoLaunchId` from this + // call because `chainingFirstUseToNewInstall` is already true — the + // auto-launch watcher takes the install through to a running ComfyUI + // window the same way the Configure handoff does. + await opts.handleShowProgress({ + installationId: result.entry.id, + title: `Installing — ${name}`, + apiCall: () => window.api.installInstance(result.entry!.id), + autoLaunchOnFinish: true, + opKind: 'install', + }) + // `handleShowProgress` swaps the first-use takeover for the + // install-progress takeover. Push `'post-consent'` so the file-menu + // builder stays locked down for the duration of the install. + window.api.setFirstUseMode('post-consent') + return true + } catch (err) { + console.warn('[firstUseChain] express install failed; falling back to Configure', err) + chainingFirstUseToNewInstall.value = false + pendingFirstUseAutoLaunchId.value = null + return false + } + } + /** InstallWizardModal `back-to-local-branch` emit. Silent Tier 3 → Tier 3 * swap that re-opens the FirstUseTakeover on its localBranch * sub-step (the step the user came from). Drops chain bookkeeping so From 47137447d717496c6f5cc5cedb0ac6800b95c131 Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 16:33:06 +0530 Subject: [PATCH 19/84] chore(i18n): busy copy for merged first-use Continue Add startContinueBusy for the spinner phase during express preparation. --- locales/en.json | 1 + locales/zh.json | 1 + 2 files changed, 2 insertions(+) diff --git a/locales/en.json b/locales/en.json index 6ee33e4e..b7b0ea21 100644 --- a/locales/en.json +++ b/locales/en.json @@ -461,6 +461,7 @@ "consentTelemetryHint": "Help improve Comfy by sharing anonymous usage data.", "consentGetStarted": "Get Started", "startContinue": "Continue", + "startContinueBusy": "Preparing your install…", "expressInstallLabel": "Express Install", "expressInstallHint": "Use recommended settings — skip optional setup steps.", "expressInstallLine": "Express Install with recommended settings", diff --git a/locales/zh.json b/locales/zh.json index 9ada4f81..e2c72d3d 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -331,6 +331,7 @@ "consentTelemetryHint": "分享匿名使用数据,帮助改进 Comfy。", "consentGetStarted": "开始使用", "startContinue": "继续", + "startContinueBusy": "正在准备安装…", "expressInstallLabel": "快捷安装", "expressInstallHint": "使用推荐设置——跳过可选的安装步骤。", "expressInstallLine": "快捷安装——使用推荐设置", From 64082809070f1b8735e4a509c3611d6ff8aeea95 Mon Sep 17 00:00:00 2001 From: Maanil Verma Date: Sun, 24 May 2026 16:33:22 +0530 Subject: [PATCH 20/84] feat(first-use): wire express branching and Continue busy state Use Tooltip primitive for cloud hint, pass express payload on chain- local when legacy express bypass applies, spinner + aria-busy on multi- IPC continuation. --- src/renderer/src/views/FirstUseTakeover.vue | 60 ++++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/views/FirstUseTakeover.vue b/src/renderer/src/views/FirstUseTakeover.vue index d36afb22..4fcb4f85 100644 --- a/src/renderer/src/views/FirstUseTakeover.vue +++ b/src/renderer/src/views/FirstUseTakeover.vue @@ -43,13 +43,13 @@ * are reset. */ import { ref, computed, onMounted, onUnmounted, watch } from 'vue' -import { Check, Copy, FolderInput, Info } from 'lucide-vue-next' +import { Check, Copy, FolderInput, Info, Loader2 } from 'lucide-vue-next' import TakeoverHeader from '../components/TakeoverHeader.vue' import ModalShell from '../components/ModalShell.vue' import ChoiceCard from '../components/ChoiceCard.vue' import WhyTryCloudModal from '../components/WhyTryCloudModal.vue' import TermsModal from '../components/TermsModal.vue' -import TooltipWrap from '../components/TooltipWrap.vue' +import Tooltip from '../components/ui/Tooltip.vue' import BrandTakeoverLayout from '../components/BrandTakeoverLayout.vue' import { emitTelemetryAction } from '../lib/telemetry' @@ -72,8 +72,11 @@ const emit = defineEmits<{ * happens inline on the Configure screen now. The optional payload * flags whether the chain was reached via the Local → Start Fresh * sub-step (vs. the direct no-legacy path) so the Configure screen - * can surface a Back link to return the user to localBranch. */ - 'chain-local': [payload?: { cameFromLocalBranch?: boolean }] + * can surface a Back link to return the user to localBranch, and + * whether the user opted into Express Install (skip Configure and + * run Standalone + recommended defaults straight through to the + * install-progress takeover). */ + 'chain-local': [payload?: { cameFromLocalBranch?: boolean; express?: boolean }] /** Local-branch follow-up: a Legacy Desktop install was detected * and the user chose to migrate it instead of installing fresh. * Host runs the migration flow (`useMigrateAction.confirmMigration` @@ -139,6 +142,11 @@ const termsDoc = ref<'eula' | 'tos' | 'privacy' | 'notices' | null>(null) * telemetry checkbox is a separate, optional opt-in (see * `telemetryEnabled`). */ const acceptedTos = ref(false) +/** True while Continue's downstream work (telemetry persist + Express + * prep IPC chain) is in flight. Drives the button's spinner + disabled + * state so the user gets feedback instead of staring at an unchanged + * screen during the multi-IPC express-install pre-roll. */ +const isContinuing = ref(false) const isChinese = computed(() => locale.value.startsWith('zh')) @@ -158,11 +166,15 @@ const isBrandStep = computed(() => step.value === 'start' || step.value === 'loc * China-mirror sub-step still runs first when the locale calls for * it; the post-mirror branch reuses the same routing logic. */ async function onContinue(): Promise { - if (!acceptedTos.value) return + if (!acceptedTos.value || isContinuing.value) return + // Keep `isContinuing` true past `routePostStart()` because the chain + // handlers (express prep, cloud auto-launch, new-install swap) all + // either unmount this takeover or swap to a sub-step within ms. The + // China-mirrors branch is the only path that lingers on this component + // post-Continue, so it explicitly clears the flag on its return. + isContinuing.value = true await window.api.setSetting('telemetryEnabled', telemetryEnabled.value) - // TODO(express-install): persist `expressInstall` pref + skip optional - // setup steps when the functional wiring lands. emitTelemetryAction('desktop2.first_use.consent_decision', { decision: telemetryEnabled.value ? 'accept' : 'decline', @@ -177,6 +189,7 @@ async function onContinue(): Promise { if (isChinese.value) { step.value = 'mirrors' + isContinuing.value = false return } @@ -196,11 +209,15 @@ function routePostStart(): void { if (pickedChoice.value === 'cloud') { emitCompleted('cloud') emit('complete-cloud') - } else if (hasLegacyDesktop.value) { + } else if (hasLegacyDesktop.value && !expressInstall.value) { + // Express takes precedence: when checked, skip the migrate-vs-fresh + // sub-step and head straight to the express Standalone install. Only + // surface the localBranch fork when Express is unticked. step.value = 'localBranch' + isContinuing.value = false } else { emitCompleted('local-new') - emit('chain-local') + emit('chain-local', { express: expressInstall.value }) } } @@ -366,7 +383,7 @@ defineExpose({ open }) @click="pickedChoice = 'cloud'" > - {{ $t('firstUse.startContinue') }} +