Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
79314b233c chore: disable CodeRabbit docstring coverage pre-merge check
The docstring coverage check is enabled via CodeRabbit organization
settings and currently blocks merges. Override it at the repo level by
setting reviews.pre_merge_checks.docstrings.mode to 'off', which takes
precedence over org settings without affecting other org repos.
2026-06-19 15:29:01 -07:00
46 changed files with 237 additions and 1131 deletions

View File

@@ -15,6 +15,11 @@ reviews:
- github-actions[bot]
pre_merge_checks:
override_requested_reviewers_only: true
# Explicitly disable the built-in docstring coverage check, which is
# enabled via organization-level settings. This repo opts out at the
# repo level without affecting other org repos.
docstrings:
mode: 'off'
custom_checks:
- name: End-to-end regression coverage for fixes
mode: error

View File

@@ -5,6 +5,7 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
@@ -41,6 +42,7 @@ setup((app) => {
}
}
})
app.use(ConfirmationService)
app.use(ToastService)
})

View File

@@ -8,7 +8,7 @@ export class BaseDialog {
public readonly page: Page,
testId?: string
) {
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
this.closeButton = this.root.getByRole('button', { name: 'Close' })
}

View File

@@ -36,11 +36,9 @@ export class BuilderSaveAsHelper {
this.closeButton = this.successDialog
.getByRole('button', { name: 'Close', exact: true })
.filter({ hasText: 'Close' })
// The icon-only X carries an aria-label, while the footer Close button
// is named by its text — getByLabel only matches the former.
this.dismissButton = this.successDialog.getByLabel('Close', {
exact: true
})
this.dismissButton = this.successDialog.locator(
'button.p-dialog-close-button'
)
this.exitBuilderButton = this.successDialog.getByRole('button', {
name: 'Exit builder'
})

View File

@@ -38,6 +38,7 @@ export const TestIds = {
settings: 'settings-dialog',
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
errorOverlayDismiss: 'error-overlay-dismiss',

View File

@@ -99,15 +99,15 @@ async function mockShareableAssets(
}
/**
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
* auth-triggered modals by pressing Escape until they clear.
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
* or auth-triggered modals by pressing Escape until they clear.
*/
async function dismissOverlays(page: Page): Promise<void> {
const dialogs = page.getByRole('dialog')
const mask = page.locator('.p-dialog-mask')
for (let attempt = 0; attempt < 3; attempt++) {
if ((await dialogs.count()) === 0) break
if ((await mask.count()) === 0) break
await page.keyboard.press('Escape')
await dialogs
await mask
.first()
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {})

View File

@@ -612,23 +612,18 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
comfyPage
}) => {
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
// around each individual gesture. Holding the modifiers down across all
// three drags plus the intervening screenshot assertions could saturate the
// main thread and stall a single mouse.move step past the test timeout, and
// a mid-test failure would leave the modifiers stuck down. Releasing per
// gesture matches the robust pattern used in canvasSettings.spec.ts.
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
await comfyPage.canvasOps.ctrlShiftDrag(
{ x: 10, y: 280 },
{ x: 10, y: 220 }
)
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-default-ctrl-shift.png'
)
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('Shift')
})
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({

View File

@@ -44,30 +44,14 @@ describe('GlobalDialog renderer branching', () => {
cleanup()
})
it('renders the Reka branch when renderer is omitted (default)', async () => {
it('renders the PrimeVue branch when renderer is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'renderer-default',
title: 'Default renderer dialog',
component: Body
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
})
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-escape-hatch',
key: 'primevue-default',
title: 'PrimeVue dialog',
component: Body,
dialogComponentProps: { renderer: 'primevue' }
component: Body
})
const dialogs = await screen.findAllByRole('dialog')

View File

@@ -1,54 +0,0 @@
/**
* Dialog migration regression net: the showConfirmDialog helper must open
* its dialog through the Reka renderer with zeroed section padding (the
* Confirm* sections carry their own). Catches accidental reverts of the
* Phase 6 renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog })
}))
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
describe('showConfirmDialog Reka renderer opt-in', () => {
beforeEach(() => {
showDialog.mockReset()
})
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
showConfirmDialog()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('md')
expect(args.dialogComponentProps.headerClass).toBe('p-0')
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
expect(args.dialogComponentProps.footerClass).toBe('p-0')
expect(args.dialogComponentProps.pt).toBeUndefined()
})
it('forwards the confirm section components and caller props', () => {
showConfirmDialog({
key: 'confirm-test',
headerProps: { title: 'Title' },
props: { promptText: 'Prompt' },
footerProps: { confirmText: 'Delete' }
})
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('confirm-test')
expect(args.headerComponent).toBe(ConfirmHeader)
expect(args.component).toBe(ConfirmBody)
expect(args.footerComponent).toBe(ConfirmFooter)
expect(args.headerProps).toEqual({ title: 'Title' })
expect(args.props).toEqual({ promptText: 'Prompt' })
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
})
})

View File

@@ -1,7 +1,6 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
@@ -12,9 +11,7 @@ interface ConfirmDialogOptions {
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
export function showConfirmDialog(
options: ConfirmDialogOptions = {}
): DialogInstance {
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { key, headerProps, props, footerProps } = options
return dialogStore.showDialog({
@@ -26,13 +23,11 @@ export function showConfirmDialog(
props,
footerProps,
dialogComponentProps: {
renderer: 'reka',
size: 'md',
// Confirm sections carry their own padding — zero out the dialog
// chrome padding, like the PrimeVue `pt` overrides did.
headerClass: 'p-0',
bodyClass: 'p-0',
footerClass: 'p-0'
pt: {
header: 'py-0! px-0!',
content: 'p-0!',
footer: 'p-0!'
}
}
})
}

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, Ref } from 'vue'
import type { CSSProperties } from 'vue'
import { ref, watch } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
@@ -15,14 +15,7 @@ export interface PositionConfig {
scale?: number
}
interface UseAbsolutePositionReturn {
style: Ref<CSSProperties>
updatePosition: (config: PositionConfig) => void
}
export function useAbsolutePosition(
options: { useTransform?: boolean } = {}
): UseAbsolutePositionReturn {
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
const { useTransform = false } = options
const canvasStore = useCanvasStore()

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, Ref } from 'vue'
import type { CSSProperties } from 'vue'
import { ref } from 'vue'
interface Rect {
@@ -28,26 +28,7 @@ interface ClippingOptions {
margin?: number
}
interface UseDomClippingReturn {
style: Ref<CSSProperties>
updateClipPath: (
element: HTMLElement,
canvasElement: HTMLCanvasElement,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
) => void
}
export function useDomClipping(
options: ClippingOptions = {}
): UseDomClippingReturn {
export const useDomClipping = (options: ClippingOptions = {}) => {
const style = ref<CSSProperties>({})
const { margin = 4 } = options

View File

@@ -1,4 +1,4 @@
import type { ComputedRef, CSSProperties, Ref } from 'vue'
import type { CSSProperties, Ref } from 'vue'
import { computed, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
@@ -8,23 +8,10 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const PREVIEW_WIDTH = 200
const PREVIEW_MARGIN = 16
interface UseNodePreviewAndDragReturn {
previewRef: Ref<HTMLElement | null>
isHovered: Ref<boolean>
isDragging: Ref<boolean>
showPreview: ComputedRef<boolean>
nodePreviewStyle: Ref<CSSProperties>
sidebarLocation: ComputedRef<'left' | 'right'>
handleMouseEnter: (e: MouseEvent) => void
handleMouseLeave: () => void
handleDragStart: (e: DragEvent) => void
handleDragEnd: (e: DragEvent) => void
}
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
panelRef?: Ref<HTMLElement | null>
): UseNodePreviewAndDragReturn {
) {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>

View File

@@ -9,13 +9,19 @@ export const useQueueClearHistoryDialog = () => {
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
renderer: 'reka',
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
// The content draws its own panel — neutralize the chrome box.
contentClass: 'w-fit max-w-90 border-none bg-transparent shadow-none'
pt: {
root: {
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
},
content: {
class: 'bg-transparent',
style: 'padding: 0'
}
}
}
})
}

View File

@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
export type ResizeDirection =
type ResizeDirection =
| 'top'
| 'bottom'
| 'left'
@@ -17,17 +17,6 @@ export type ResizeDirection =
| 'sw'
| 'se'
export interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const HANDLE_SIZE = 8
const CORNER_SIZE = 10
/** Minimum crop width/height in source image pixel space. */
@@ -275,6 +264,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
height: `${cropHeight.value * scaleFactor.value}px`
}))
interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
const allResizeHandles = computed<ResizeHandle[]>(() => {

View File

@@ -36,15 +36,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
options?.afterClose?.()
},
initialCategory
},
// The template browser is a wide layout. Without an explicit size the
// Reka DialogContent falls back to size 'md' (max-w-xl), clipping the
// filter bar so the Clear Filters button lands outside the viewport.
// Size it like the other large dialogs (Settings/Manager).
dialogComponentProps: {
size: 'full',
contentClass:
'w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[80vh] rounded-2xl overflow-hidden'
}
})
}

View File

@@ -1,94 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
vi.stubGlobal('fetch', vi.fn())
describe('getComfyApiBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_api_base_url: '' }
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
})
describe('getComfyPlatformBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = {
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
}
expect(getComfyPlatformBaseUrl()).toBe(
'https://my-ephem-platform.example.com'
)
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_platform_base_url: '' }
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})
describe('compatibility with comfyui servers that predate the override keys', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('falls back to build-time defaults when /features omits the URL keys', async () => {
// An older comfyui server has /features but doesn't know about
// comfy_api_base_url / comfy_platform_base_url yet.
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
supports_preview_metadata: true,
max_upload_size: 104857600
})
} as Response)
await refreshRemoteConfig({ useAuth: false })
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})

View File

@@ -1,3 +1,4 @@
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
@@ -18,14 +19,11 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
: (import.meta.env.VITE_STAGING_PLATFORM_BASE_URL ??
STAGING_PLATFORM_BASE_URL)
/**
* Resolves the ComfyUI API base URL.
*
* The local server (any distribution) is authoritative:
* whatever `/api/features` returns for `comfy_api_base_url` wins, falling back to the build-time default.
* That way the server can point its frontend at a different api host without rebuilding the frontend package.
*/
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
@@ -33,11 +31,11 @@ export function getComfyApiBaseUrl(): string {
)
}
/**
* Resolves the ComfyUI Platform base URL.
* As with the api base, the server's `/api/features` (`comfy_platform_base_url`) overrides the build-time default.
*/
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',

View File

@@ -1,46 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getFirebaseConfig } from './firebase'
describe('getFirebaseConfig', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors a full server-provided firebase_config (cloud builds)', () => {
const cloud = {
apiKey: 'cloud-key',
authDomain: 'cloud.example.com',
projectId: 'some-cloud-project',
storageBucket: 'cloud.appspot.com',
messagingSenderId: '1',
appId: '1:1:web:abc'
}
remoteConfig.value = { firebase_config: cloud }
expect(getFirebaseConfig()).toEqual(cloud)
})
it('uses the dev project for a staging-tier api base (staging or testenv)', () => {
// No firebase_config from the server — the dev project is derived from the
// api base, using the DEV config bundled in the frontend.
remoteConfig.value = { comfy_api_base_url: 'https://stagingapi.comfy.org' }
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
remoteConfig.value = {
comfy_api_base_url: 'https://pr-1-registry.testenvs.comfy.org'
}
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
it('falls back to the build-time config otherwise', () => {
// The test build uses the non-prod config => dreamboothy-dev.
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
})

View File

@@ -1,5 +1,6 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
@@ -26,28 +27,16 @@ const PROD_CONFIG: FirebaseOptions = {
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
const STAGING_API_HOST = 'stagingapi.comfy.org'
const TESTENV_HOST_SUFFIX = '.testenvs.comfy.org'
// staging + the ephemeral testenvs use the dev Firebase project (prod uses prod)
function isStagingTierApiBase(apiBase: string | undefined): boolean {
if (!apiBase) return false
try {
const host = new URL(apiBase).hostname
return host === STAGING_API_HOST || host.endsWith(TESTENV_HOST_SUFFIX)
} catch {
return false
}
}
/**
* Firebase config for the current backend: the server's firebase_config (cloud builds),
* else the bundled DEV_CONFIG when the api base is staging-tier, else the build-time default.
* Returns the Firebase configuration for the current environment.
* - Cloud builds use runtime configuration delivered via feature flags
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
if (runtimeConfig) return runtimeConfig
if (isStagingTierApiBase(remoteConfig.value.comfy_api_base_url))
return DEV_CONFIG
return BUILD_TIME_CONFIG
return runtimeConfig ?? BUILD_TIME_CONFIG
}

View File

@@ -154,10 +154,8 @@ export const i18n = createI18n({
})
/** Convenience shorthand: i18n.global */
export const t: (typeof i18n.global)['t'] = i18n.global.t
export const te: (typeof i18n.global)['te'] = i18n.global.te
export const d: (typeof i18n.global)['d'] = i18n.global.d
const tm = i18n.global.tm
export const { t, te, d } = i18n.global
const { tm } = i18n.global
/**
* Safe translation function that returns the fallback message if the key is not found.

View File

@@ -3340,7 +3340,7 @@
"mediaLabel": "{count} Media File | {count} Media Files",
"modelsLabel": "{count} Model | {count} Models",
"checkingAssets": "Checking media visibility…",
"acknowledgeCheckbox": "I understand anyone with the link can view these files",
"acknowledgeCheckbox": "I understand these media items will be published and made public",
"inLibrary": "In library",
"comfyHubTitle": "Upload to ComfyHub",
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",

View File

@@ -5,6 +5,7 @@ import { initializeApp } from 'firebase/app'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
@@ -30,18 +31,15 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
/**
* CRITICAL: Load remote config FIRST so window.__CONFIG__ is available for all modules during initialization.
* The local /api/features endpoint is the source of truth for runtime config (api base, Firebase project, …).
* Allows the server to dictate which backend the frontend talks to and which Firebase project it logs in against.
* Must run before initializeApp() below so getFirebaseConfig() sees it.
*/
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
if (requiresRemoteConfigBootstrap) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
}
if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
@@ -135,6 +133,7 @@ app
}
}
})
.use(ConfirmationService)
.use(ToastService)
.use(pinia)
.use(i18n)

View File

@@ -144,6 +144,7 @@ import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -157,10 +158,7 @@ import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import {
getAssetDisplayName,
resolveDisplayImageDimensions
} from '../utils/assetMetadataUtils'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
@@ -281,15 +279,12 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const displayImageDimensions = computed(() =>
resolveDisplayImageDimensions(asset, imageDimensions.value)
)
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && displayImageDimensions.value) {
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)

View File

@@ -774,10 +774,6 @@ export function useMediaAssetActions() {
onCancel: () => {
resolve(false)
}
},
dialogComponentProps: {
renderer: 'reka',
size: 'md'
}
})
})

View File

@@ -13,15 +13,6 @@ import { useDialogStore } from '@/stores/dialogStore'
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
// Contents bring their own width and padding — shrink-wrap the chrome and
// zero the section padding (the PrimeVue `pt` overrides this replaces).
const uploadDialogComponentProps = {
renderer: 'reka',
contentClass: 'w-fit max-w-[calc(100vw-1rem)]',
headerClass: 'py-0 pl-0',
bodyClass: 'p-0 overflow-y-hidden'
} as const
export function useModelUpload(
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
@@ -40,7 +31,12 @@ export function useModelUpload(
key: 'upload-model-upgrade',
headerComponent: UploadModelUpgradeModalHeader,
component: UploadModelUpgradeModal,
dialogComponentProps: uploadDialogComponentProps
dialogComponentProps: {
pt: {
header: 'py-0! pl-0!',
content: 'p-0! overflow-y-hidden!'
}
}
})
} else {
dialogStore.showDialog({
@@ -53,7 +49,12 @@ export function useModelUpload(
await onUploadSuccess?.(result)
}
},
dialogComponentProps: uploadDialogComponentProps
dialogComponentProps: {
pt: {
header: 'py-0! pl-0!',
content: 'p-0! overflow-y-hidden!'
}
}
})
}
}

View File

@@ -10,14 +10,12 @@ import {
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetMetadataDimensions,
getAssetModelType,
getAssetSourceUrl,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName,
resolveDisplayImageDimensions
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
@@ -419,124 +417,4 @@ describe('assetMetadataUtils', () => {
expect(getAssetCardTitle(asset)).toBe('pretty.png')
})
})
describe('getAssetMetadataDimensions', () => {
it('returns dimensions when width/height are positive integers', () => {
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
expect(getAssetMetadataDimensions(asset)).toEqual({
width: 1024,
height: 768
})
})
it.for([
{ name: 'NaN width', width: Number.NaN, height: 768 },
{
name: 'Infinity height',
width: 1024,
height: Number.POSITIVE_INFINITY
},
{ name: 'zero width', width: 0, height: 768 },
{ name: 'negative height', width: 1024, height: -1 },
{ name: 'fractional width', width: 1024.5, height: 768 },
{ name: 'string width', width: '1024', height: 768 },
{ name: 'missing width', width: undefined, height: 768 }
])('returns undefined for invalid shape: $name', ({ width, height }) => {
const asset = { ...mockAsset, metadata: { width, height } }
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
})
it('returns undefined when metadata is absent', () => {
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
})
it('returns undefined when asset itself is undefined', () => {
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
})
})
describe('resolveDisplayImageDimensions', () => {
const rendered = { width: 512, height: 288 }
it('prefers server metadata dimensions over the rendered natural size', () => {
const asset = { ...mockAsset, metadata: { width: 1920, height: 1080 } }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
width: 1920,
height: 1080
})
})
it('prefers metadata even when a downscaled thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp',
metadata: { width: 1920, height: 1080 }
}
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
width: 1920,
height: 1080
})
})
it('falls back to the rendered natural size when no thumbnail was shown (original served)', () => {
const asset = { ...mockAsset }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('falls back to the rendered natural size on OSS where thumbnail_url equals preview_url (full-res)', () => {
const fullResUrl =
'http://localhost:8188/view?filename=output.png&type=output'
const asset = {
...mockAsset,
thumbnail_url: fullResUrl,
preview_url: fullResUrl
}
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('returns undefined (no label) when metadata is absent and a distinct downscaled thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp'
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('suppresses the fallback for an invalid metadata shape when a distinct thumbnail was rendered', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
preview_url: 'https://cdn.example/original.webp',
metadata: { width: 0, height: 1080 }
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('suppresses the fallback when thumbnail_url is present but preview_url is absent', () => {
const asset = {
...mockAsset,
thumbnail_url: 'https://cdn.example/thumb.webp'
}
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
})
it('falls back to the rendered natural size when metadata is invalid and no thumbnail guard applies', () => {
const asset = { ...mockAsset, metadata: { width: 0, height: 1080 } }
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
})
it('returns undefined when neither metadata nor a rendered size is available', () => {
expect(
resolveDisplayImageDimensions(mockAsset, undefined)
).toBeUndefined()
})
it('returns the rendered size when asset is undefined (no thumbnail to guard against)', () => {
expect(resolveDisplayImageDimensions(undefined, rendered)).toEqual(
rendered
)
})
})
})

View File

@@ -216,64 +216,6 @@ export function getAssetCardTitle(asset: AssetItem): string {
return getAssetDisplayFilename(asset)
}
export interface ImageDimensions {
width: number
height: number
}
/**
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
* NaN, Infinity, 0, negatives, and fractional values.
*/
function isValidDimension(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0
}
/**
* Returns the original image dimensions from `asset.metadata.{width,height}`
* when both pass shape validation, otherwise `undefined`. Callers should fall
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
* on runtimes that serve the original file but reports preview size on
* runtimes that serve a downscaled preview.
*/
export function getAssetMetadataDimensions(
asset: AssetItem | undefined
): ImageDimensions | undefined {
const w = asset?.metadata?.width
const h = asset?.metadata?.height
if (isValidDimension(w) && isValidDimension(h)) {
return { width: w, height: h }
}
return undefined
}
/**
* Resolves the image dimensions an asset card should display.
*
* Prefers the server-provided original dimensions from
* {@link getAssetMetadataDimensions}. Only when those are absent does it fall
* back to `renderedNaturalSize` — the natural size of the `<img>` the card
* actually rendered — and only when that rendered image was the original file.
*
* A distinct `thumbnail_url` (one that differs from `preview_url`) means the
* card rendered a downscaled preview, so `renderedNaturalSize` reflects the
* preview's dimensions rather than the asset's. In that case this returns
* `undefined` so the card shows no label rather than a wrong resolution.
* On OSS, `thumbnail_url` and `preview_url` are the same URL (full-res),
* so the guard correctly passes through `renderedNaturalSize`.
*/
export function resolveDisplayImageDimensions(
asset: AssetItem | undefined,
renderedNaturalSize: ImageDimensions | undefined
): ImageDimensions | undefined {
const fromMetadata = getAssetMetadataDimensions(asset)
if (fromMetadata) return fromMetadata
if (asset?.thumbnail_url && asset.thumbnail_url !== asset.preview_url)
return undefined
return renderedNaturalSize
}
/**
* Returns the filename component the cloud `/api/view` endpoint resolves
* for this asset — `hash` when present (cloud assets are hash-keyed

View File

@@ -49,9 +49,13 @@ export const useSubscriptionDialog = () => {
),
props: { onClose: hide },
dialogComponentProps: {
renderer: 'reka',
contentClass:
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
style: 'width: min(360px, 95vw);',
pt: {
root: {
class: 'bg-transparent border-none rounded-none shadow-none'
},
content: { class: '!p-0 bg-transparent border-none shadow-none' }
}
}
})
return
@@ -85,14 +89,16 @@ export const useSubscriptionDialog = () => {
component,
props: useWorkspaceVariant ? workspaceProps : personalProps,
dialogComponentProps: {
renderer: 'reka',
size: 'full',
// The pricing tables host a PrimeVue Popover teleported to body.
// Reka's modal mode traps focus and disables body pointer-events,
// making the popover unclickable. Mirrors Settings/Manager.
modal: false,
contentClass:
'w-[min(1328px,95vw)] max-w-[min(1328px,95vw)] sm:max-w-[min(1328px,95vw)] h-full max-h-[958px] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
style: 'width: min(1328px, 95vw); max-height: 958px;',
pt: {
root: {
class: 'rounded-2xl bg-transparent h-full'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
}
}
}
})
}
@@ -116,10 +122,16 @@ export const useSubscriptionDialog = () => {
}
},
dialogComponentProps: {
renderer: 'reka',
size: 'full',
contentClass:
'w-[min(640px,95vw)] max-w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
style: 'width: min(640px, 95vw);',
pt: {
root: {
class: 'rounded-2xl bg-transparent'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}
})
return

View File

@@ -43,10 +43,9 @@ describe('refreshRemoteConfig', () => {
await refreshRemoteConfig({ useAuth: true })
expect(api.fetchApi).toHaveBeenCalledWith(
'/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
cache: 'no-store'
})
expect(global.fetch).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
@@ -68,38 +67,15 @@ describe('refreshRemoteConfig', () => {
await refreshRemoteConfig({ useAuth: false })
expect(global.fetch).toHaveBeenCalledWith(
'/api/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
cache: 'no-store'
})
expect(api.fetchApi).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
})
})
describe('timeout', () => {
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
const init = vi.mocked(global.fetch).mock.calls[0][1]
expect(init?.signal).toBeInstanceOf(AbortSignal)
})
it('falls back to empty config when the request aborts', async () => {
vi.mocked(global.fetch).mockRejectedValue(
new DOMException('Aborted', 'AbortError')
)
await refreshRemoteConfig({ useAuth: false })
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
})
describe('error handling', () => {
it('clears config on 401 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(

View File

@@ -4,11 +4,6 @@ import {
remoteConfigState
} from './remoteConfig'
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
// A same-origin GET against the local comfyui server should resolve in well under a second;
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
const FEATURES_FETCH_TIMEOUT_MS = 5_000
interface RefreshRemoteConfigOptions {
/**
* Whether to use authenticated API (default: true).
@@ -17,14 +12,11 @@ interface RefreshRemoteConfigOptions {
useAuth?: boolean
}
async function fetchRemoteConfig(
useAuth: boolean,
signal: AbortSignal
): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store', signal })
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
const { api } = await import('@/scripts/api')
return api.fetchApi('/features', { cache: 'no-store', signal })
return api.fetchApi('/features', { cache: 'no-store' })
}
/**
@@ -41,14 +33,8 @@ export async function refreshRemoteConfig(
): Promise<void> {
const { useAuth = true } = options
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
FEATURES_FETCH_TIMEOUT_MS
)
try {
const response = await fetchRemoteConfig(useAuth, controller.signal)
const response = await fetchRemoteConfig(useAuth)
if (response.ok) {
const config = await response.json()
@@ -73,7 +59,5 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
} finally {
clearTimeout(timeoutId)
}
}

View File

@@ -66,7 +66,11 @@ export function useShareDialog() {
onClose: hide
},
dialogComponentProps: {
contentClass: 'sm:max-w-144 rounded-2xl overflow-hidden'
pt: {
root: {
class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
}
}
}
})
}

View File

@@ -110,7 +110,11 @@ export function useSharedWorkflowUrlLoader() {
},
dialogComponentProps: {
onClose: () => resolve({ action: 'cancel' }),
contentClass: 'sm:max-w-176 rounded-2xl overflow-hidden'
pt: {
root: {
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
}
}
}
})
})

View File

@@ -31,11 +31,6 @@ export interface Member {
email: string
joined_at: string
role: WorkspaceRole
// True when this member is the workspace's original owner/creator
// (member.id == workspace.created_by_user_id). Gates the creator-only
// billing lifecycle actions (cancel / reactivate / downgrade).
// Optional: the cloud OpenAPI does not carry this field yet.
is_original_owner?: boolean
}
interface PaginationInfo {

View File

@@ -113,11 +113,7 @@
button-variant="gradient"
/>
<Button
v-if="
showSubscribeAction &&
!isPersonalWorkspace &&
(!isCancelled || permissions.canManageSubscriptionLifecycle)
"
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"

View File

@@ -128,10 +128,9 @@
v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
>
<!-- Cancelled state: reactivation is original-owner-only. -->
<!-- Cancelled state: show only Resubscribe button -->
<template v-if="isCancelled">
<Button
v-if="permissions.canManageSubscriptionLifecycle"
size="lg"
variant="primary"
class="rounded-lg px-4 text-sm font-normal"
@@ -162,7 +161,7 @@
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-if="!isFreeTierPlan && planMenuItems.length > 0"
v-if="!isFreeTierPlan"
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="lg"
@@ -514,23 +513,15 @@ const subscriptionTierName = computed(() => {
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
// and the "more options" button is hidden (see template).
const planMenuItems = computed(() =>
permissions.value.canManageSubscriptionLifecycle
? [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(
subscription.value?.endDate ?? undefined
)
}
}
]
: []
)
const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: () => {
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
}
}
])
const tierKey = computed(() => {
const tier = subscriptionTier.value

View File

@@ -82,8 +82,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
name: 'Owner User',
email: 'owner@example.com',
role: 'owner' as const,
joinDate: new Date(0),
isOriginalOwner: true
joinDate: new Date(0)
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
@@ -154,7 +153,6 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}

View File

@@ -21,7 +21,6 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}

View File

@@ -111,8 +111,7 @@ export function useMembersPanel() {
name: userDisplayName.value ?? '',
email: userEmail.value ?? '',
role: 'owner' as const,
joinDate: new Date(0),
isOriginalOwner: true
joinDate: new Date(0)
}))
const searchQuery = ref('')

View File

@@ -2,21 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
const mockStore = vi.hoisted(() => ({
activeWorkspace: null as WorkspaceWithRole | null,
isCurrentUserOriginalOwner: false,
ensureMembersLoaded: vi.fn()
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockStore.activeWorkspace
},
get isCurrentUserOriginalOwner() {
return mockStore.isCurrentUserOriginalOwner
},
ensureMembersLoaded: mockStore.ensureMembersLoaded
return mockActiveWorkspace.value
}
})
}))
@@ -52,20 +46,14 @@ async function loadComposable() {
return module.useWorkspaceUI()
}
function resetStore() {
mockStore.activeWorkspace = null
mockStore.isCurrentUserOriginalOwner = false
mockStore.ensureMembersLoaded.mockReset()
}
describe('useWorkspaceUI', () => {
beforeEach(() => {
vi.resetModules()
resetStore()
mockActiveWorkspace.value = null
})
afterEach(() => {
resetStore()
mockActiveWorkspace.value = null
})
describe('when no active workspace', () => {
@@ -83,7 +71,7 @@ describe('useWorkspaceUI', () => {
describe('personal workspace', () => {
beforeEach(() => {
mockStore.activeWorkspace = personalWorkspace
mockActiveWorkspace.value = personalWorkspace
})
it('grants billing access but disables team management', async () => {
@@ -131,7 +119,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as owner', () => {
beforeEach(() => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockActiveWorkspace.value = teamOwnerWorkspace
})
it('grants full management permissions', async () => {
@@ -171,7 +159,7 @@ describe('useWorkspaceUI', () => {
describe('team workspace as member', () => {
beforeEach(() => {
mockStore.activeWorkspace = teamMemberWorkspace
mockActiveWorkspace.value = teamMemberWorkspace
})
it('restricts management actions while allowing leave', async () => {
@@ -207,60 +195,9 @@ describe('useWorkspaceUI', () => {
})
})
// Drives off the members-list self-row original-owner signal, surfaced by the
// store getter `isCurrentUserOriginalOwner`.
describe('subscription lifecycle (creator-only)', () => {
it('grants lifecycle to the personal-workspace sole owner', async () => {
mockStore.activeWorkspace = personalWorkspace
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
})
it('grants lifecycle to a team owner who is the original owner', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = true
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscription).toBe(true)
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
})
it('withholds lifecycle from a promoted (non-creator) team owner', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = false
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscription).toBe(true)
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('fails closed while the members list is still loading', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockStore.isCurrentUserOriginalOwner = false
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('withholds lifecycle from members', async () => {
mockStore.activeWorkspace = teamMemberWorkspace
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
})
it('delegates member loading to the store when a team workspace becomes active', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
await loadComposable()
expect(mockStore.ensureMembersLoaded).toHaveBeenCalled()
})
it('does not load members for a personal workspace', async () => {
mockStore.activeWorkspace = personalWorkspace
await loadComposable()
expect(mockStore.ensureMembersLoaded).not.toHaveBeenCalled()
})
})
describe('shared instance', () => {
it('returns the same composable state for multiple callers within a test', async () => {
mockStore.activeWorkspace = teamOwnerWorkspace
mockActiveWorkspace.value = teamOwnerWorkspace
const first = await loadComposable()
const second = await loadComposable()

View File

@@ -1,4 +1,4 @@
import { computed, watch } from 'vue'
import { computed } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
@@ -14,10 +14,6 @@ interface WorkspacePermissions {
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
// Creator-only subscription lifecycle: cancel / reactivate / downgrade.
// Any owner has `canManageSubscription` (manage payment, top-up, change
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
canManageSubscriptionLifecycle: boolean
canTopUp: boolean
}
@@ -38,8 +34,7 @@ interface WorkspaceUIConfig {
function getPermissions(
type: WorkspaceType,
role: WorkspaceRole,
isOriginalOwner: boolean
role: WorkspaceRole
): WorkspacePermissions {
if (type === 'personal') {
return {
@@ -51,8 +46,6 @@ function getPermissions(
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true,
// Personal workspace is single-member: the user is the sole owner/creator.
canManageSubscriptionLifecycle: true,
canTopUp: true
}
}
@@ -67,7 +60,6 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
}
}
@@ -82,7 +74,6 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
}
@@ -154,26 +145,8 @@ function useWorkspaceUIInternal() {
() => store.activeWorkspace?.role ?? 'owner'
)
// The original-owner signal lives on the members-list self-row, so a team
// workspace's members must be loaded before its lifecycle gate can resolve.
// The store dedupes in-flight/already-loaded requests and logs failures;
// until members arrive the getter fails closed.
watch(
() => store.activeWorkspace?.id,
() => {
if (store.activeWorkspace?.type === 'team') {
void store.ensureMembersLoaded()
}
},
{ immediate: true }
)
const permissions = computed<WorkspacePermissions>(() =>
getPermissions(
workspaceType.value,
workspaceRole.value,
store.isCurrentUserOriginalOwner
)
getPermissions(workspaceType.value, workspaceRole.value)
)
const uiConfig = computed<WorkspaceUIConfig>(() =>

View File

@@ -29,15 +29,6 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
}))
// Mock current user (drives the original-owner self-row match by email)
const mockCurrentUser = vi.hoisted(() => ({
userEmail: { value: null as string | null }
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail })
}))
// Mock workspaceApi
const mockWorkspaceApi = vi.hoisted(() => ({
list: vi.fn(),
@@ -131,7 +122,6 @@ describe('useTeamWorkspaceStore', () => {
vi.clearAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
sessionStorage.clear()
mockCurrentUser.userEmail.value = null
// Reset workspaceAuthStore mock state
mockWorkspaceAuthStore.currentWorkspace = null
@@ -690,193 +680,6 @@ describe('useTeamWorkspaceStore', () => {
})
})
describe('ensureMembersLoaded', () => {
const memberRow = {
id: 'user-1',
name: 'Owner',
email: 'owner@test.com',
joined_at: '2024-01-01T00:00:00Z'
}
function mockMembersResponse() {
mockWorkspaceApi.listMembers.mockResolvedValue({
members: [memberRow],
pagination: { offset: 0, limit: 50, total: 1 }
})
}
async function activateTeamWorkspace() {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
return store
}
it('loads members for a team workspace that is not yet loaded', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
expect(store.members).toHaveLength(1)
})
it('does not load members again once loaded', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
})
it('dedupes concurrent calls into a single request', async () => {
mockMembersResponse()
const store = await activateTeamWorkspace()
await Promise.all([
store.ensureMembersLoaded(),
store.ensureMembersLoaded()
])
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
})
it('does not load members for a personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
})
it('logs a failed request and retries on the next call', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockWorkspaceApi.listMembers.mockRejectedValueOnce(new Error('boom'))
const store = await activateTeamWorkspace()
await store.ensureMembersLoaded()
expect(consoleError).toHaveBeenCalled()
expect(store.members).toHaveLength(0)
mockMembersResponse()
await store.ensureMembersLoaded()
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(2)
expect(store.members).toHaveLength(1)
consoleError.mockRestore()
})
})
describe('isCurrentUserOriginalOwner', () => {
async function loadTeamWithMembers(
members: Array<{
id: string
name: string
email: string
joined_at: string
is_original_owner?: boolean
}>
) {
mockWorkspaceApi.listMembers.mockResolvedValue({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchMembers()
return store
}
const ownerSelf = {
id: 'user-1',
name: 'Owner',
email: 'owner@test.com',
joined_at: '2024-01-01T00:00:00Z',
role: 'owner' as const,
is_original_owner: true
}
const promotedSelf = { ...ownerSelf, is_original_owner: false }
it('is true when the self-row is the original owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
it('matches the self-row by email case-insensitively', async () => {
mockCurrentUser.userEmail.value = 'OWNER@TEST.COM'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
it('is false when the self-row is a promoted (non-creator) owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const store = await loadTeamWithMembers([promotedSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when the self-row omits is_original_owner', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf
const store = await loadTeamWithMembers([selfWithoutFlag])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('is false when no member row matches the current user', async () => {
mockCurrentUser.userEmail.value = 'someone-else@test.com'
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when members are not loaded', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('fails closed when the current user email is unknown', async () => {
mockCurrentUser.userEmail.value = null
const store = await loadTeamWithMembers([ownerSelf])
expect(store.isCurrentUserOriginalOwner).toBe(false)
})
it('recomputes reactively when the self-row arrives after an empty read', async () => {
mockCurrentUser.userEmail.value = 'owner@test.com'
mockWorkspaceApi.listMembers.mockResolvedValue({
members: [ownerSelf],
pagination: { offset: 0, limit: 50, total: 1 }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isCurrentUserOriginalOwner).toBe(false)
await store.fetchMembers()
expect(store.isCurrentUserOriginalOwner).toBe(true)
})
})
describe('invite actions', () => {
it('fetchPendingInvites updates active workspace invites', async () => {
const mockInvites = [

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
@@ -22,7 +21,6 @@ export interface WorkspaceMember {
email: string
joinDate: Date
role: 'owner' | 'member'
isOriginalOwner: boolean
}
export interface PendingInvite {
@@ -51,8 +49,7 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at),
role: member.role,
isOriginalOwner: member.is_original_owner ?? false
role: member.role
}
}
@@ -149,18 +146,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
() => activeWorkspace.value?.members ?? []
)
// True when the current user is the active workspace's original owner,
// resolved from the self-row of the loaded members list. Matches by email
// (the stable current-user join key; member.id is a cloud user id, not the
// Firebase uid). Fails closed when members are not loaded or no self-row
// matches, so lifecycle gating stays hidden until the real signal arrives.
const isCurrentUserOriginalOwner = computed(() => {
const email = useCurrentUser().userEmail.value?.toLowerCase()
if (!email) return false
const selfRow = members.value.find((m) => m.email.toLowerCase() === email)
return selfRow?.isOriginalOwner ?? false
})
const pendingInvites = computed<PendingInvite[]>(
() => activeWorkspace.value?.pendingInvites ?? []
)
@@ -522,36 +507,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
return members
}
// Tracks which team workspaces have already loaded their members so the
// lifecycle gate resolves without redundant or duplicate fetches.
const loadedMemberWorkspaceIds = new Set<string>()
let inFlightMembersWorkspaceId: string | null = null
/**
* Load the active team workspace's members once. No-ops for personal or
* already-loaded workspaces and dedupes concurrent calls. A failed request is
* logged and leaves the workspace unloaded so a later call retries.
*/
async function ensureMembersLoaded(): Promise<void> {
const workspaceId = activeWorkspaceId.value
if (!workspaceId) return
if (activeWorkspace.value?.type === 'personal') return
if (loadedMemberWorkspaceIds.has(workspaceId)) return
if (inFlightMembersWorkspaceId === workspaceId) return
inFlightMembersWorkspaceId = workspaceId
try {
await fetchMembers()
loadedMemberWorkspaceIds.add(workspaceId)
} catch (e) {
console.error('Failed to load workspace members', e)
} finally {
if (inFlightMembersWorkspaceId === workspaceId) {
inFlightMembersWorkspaceId = null
}
}
}
/**
* Remove a member from the current workspace.
*/
@@ -697,7 +652,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
ownedWorkspacesCount,
canCreateWorkspace,
members,
isCurrentUserOriginalOwner,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
@@ -721,7 +675,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Member Actions
fetchMembers,
ensureMembersLoaded,
removeMember,
// Invite Actions

View File

@@ -79,56 +79,4 @@ describe('dialogService Reka renderer opt-in', () => {
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('lg')
})
it("showTopUpCreditsDialog() sets renderer 'reka' with a transparent shrink-wrapped chrome", async () => {
await useDialogService().showTopUpCreditsDialog()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.headless).toBe(true)
expect(args.dialogComponentProps.pt).toBeUndefined()
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
expect(args.dialogComponentProps.contentClass).toContain('bg-transparent')
})
it("showLayoutDialog() defaults to renderer 'reka' headless without pt", () => {
const Component = { template: '<div />' }
useDialogService().showLayoutDialog({
key: 'layout-test',
component: Component,
props: {}
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.headless).toBe(true)
expect(args.dialogComponentProps.pt).toBeUndefined()
})
it('showLayoutDialog() lets callers override the defaults', () => {
const Component = { template: '<div />' }
useDialogService().showLayoutDialog({
key: 'layout-override-test',
component: Component,
props: {},
dialogComponentProps: { closable: false, contentClass: 'w-170' }
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.closable).toBe(false)
expect(args.dialogComponentProps.contentClass).toBe('w-170')
})
it("showSmallLayoutDialog() sets renderer 'reka' with zeroed section padding", () => {
const Component = { template: '<div />' }
useDialogService().showSmallLayoutDialog({
key: 'small-layout-test',
component: Component
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.pt).toBeUndefined()
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
expect(args.dialogComponentProps.headerClass).toBe('p-0')
expect(args.dialogComponentProps.bodyClass).toBe('p-0 overflow-y-hidden')
expect(args.dialogComponentProps.footerClass).toBe('p-0')
})
})

View File

@@ -33,20 +33,6 @@ const lazyCloudNotificationContent = () =>
const lazyPublishDialog = () =>
import('@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue')
/**
* Shrink-wrap the Reka DialogContent around the content's intrinsic width,
* like the auto-sized PrimeVue root it replaces.
*/
const HUG_CONTENT_CLASS =
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)]'
/**
* Reka chrome for headless dialogs whose content draws its own panel
* (background/border/rounding) — neutralize the DialogContent box and
* shrink-wrap it around the content.
*/
const SELF_STYLED_PANEL_CONTENT_CLASS = `${HUG_CONTENT_CLASS} border-none bg-transparent shadow-none`
export type ConfirmationDialogType =
| 'default'
| 'overwrite'
@@ -213,8 +199,6 @@ export const useDialogService = () => {
},
headerComponent: ComfyOrgHeader,
dialogComponentProps: {
renderer: 'reka',
contentClass: HUG_CONTENT_CLASS,
closable: false,
onClose: () => resolve(false)
}
@@ -238,10 +222,6 @@ export const useDialogService = () => {
onSuccess: () => resolve(true)
},
dialogComponentProps: {
renderer: 'reka',
// SignInContent is a fixed w-96 — size 'sm' (max-w-sm) leaves only
// 352px after the body padding; hug the intrinsic width instead.
contentClass: HUG_CONTENT_CLASS,
closable: true,
onClose: () => resolve(false)
}
@@ -347,9 +327,12 @@ export const useDialogService = () => {
component,
props: options,
dialogComponentProps: {
renderer: 'reka',
headless: true,
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
}
})
}
@@ -368,10 +351,6 @@ export const useDialogService = () => {
props: {
onSuccess: () =>
dialogStore.closeDialog({ key: 'global-update-password' })
},
dialogComponentProps: {
renderer: 'reka',
contentClass: HUG_CONTENT_CLASS
}
})
}
@@ -401,10 +380,20 @@ export const useDialogService = () => {
dialogComponentProps?: DialogComponentProps
}) {
const layoutDefaultProps: DialogComponentProps = {
renderer: 'reka',
headless: true,
modal: true,
closable: true
closable: true,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
},
header: {
class: 'p-0! hidden'
},
content: {
class: 'p-0! m-0!'
}
}
}
return dialogStore.showDialog({
@@ -426,15 +415,18 @@ export const useDialogService = () => {
return dialogStore.showDialog({
...rest,
dialogComponentProps: {
renderer: 'reka',
closable: true,
// Contents bring their own width and separators — shrink-wrap the
// chrome and zero the section padding.
contentClass:
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-border-default',
headerClass: 'p-0',
bodyClass: 'p-0 overflow-y-hidden',
footerClass: 'p-0',
pt: {
root: { class: 'bg-base-background border-border-default' },
header: { class: '!p-0 !m-0' },
content: { class: '!p-0 overflow-y-hidden' },
footer: { class: '!p-0' },
pcCloseButton: {
root: {
class: '!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5'
}
}
},
...callerProps
}
})
@@ -454,10 +446,13 @@ export const useDialogService = () => {
}
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
const workspaceDialogProps = {
renderer: 'reka',
const workspaceDialogPt = {
headless: true,
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
} as const
async function showDeleteWorkspaceDialog(options?: {
@@ -470,7 +465,7 @@ export const useDialogService = () => {
key: 'delete-workspace',
component,
props: options,
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -484,7 +479,7 @@ export const useDialogService = () => {
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -503,7 +498,7 @@ export const useDialogService = () => {
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -514,7 +509,7 @@ export const useDialogService = () => {
return dialogStore.showDialog({
key: 'leave-workspace',
component,
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -525,7 +520,7 @@ export const useDialogService = () => {
key: 'edit-workspace',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -537,7 +532,7 @@ export const useDialogService = () => {
key: 'remove-member',
component,
props: { memberId },
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -548,7 +543,7 @@ export const useDialogService = () => {
key: 'invite-member',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -560,7 +555,7 @@ export const useDialogService = () => {
key: 'invite-member-upsell',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -572,7 +567,7 @@ export const useDialogService = () => {
key: 'revoke-invite',
component,
props: { inviteId },
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -602,7 +597,7 @@ export const useDialogService = () => {
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -617,8 +612,9 @@ export const useDialogService = () => {
props: {},
dialogComponentProps: {
closable: false,
contentClass:
'w-170 max-w-[calc(100vw-1rem)] sm:max-w-[42.5rem] rounded-2xl overflow-hidden',
pt: {
root: { class: 'w-170 max-h-[85vh]' }
},
onClose: () => resolve()
}
})
@@ -632,13 +628,12 @@ export const useDialogService = () => {
key,
component: ComfyHubPublishDialog,
props: {
onClose: () => dialogStore.closeDialog({ key }),
// Falls through to the BaseModalLayout root — keeps the e2e
// publish-dialog selector working without the PrimeVue pt hook.
'data-testid': 'publish-dialog'
onClose: () => dialogStore.closeDialog({ key })
},
dialogComponentProps: {
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
pt: {
root: { 'data-testid': 'publish-dialog' }
}
}
})
}

View File

@@ -21,10 +21,10 @@ type DialogPosition =
| 'bottomright'
/**
* Selects the dialog renderer used by `GlobalDialog`. `'reka'` (the default)
* renders the Reka-UI primitive set under `src/components/ui/dialog/`.
* `'primevue'` is the legacy PrimeVue `Dialog` escape hatch, kept only until
* the branch is deleted in the Phase 6 cleanup (FE-578).
* Selects the dialog renderer used by `GlobalDialog`. `'primevue'` is the
* current default and runs the legacy PrimeVue `Dialog` path. `'reka'` opts
* into the Reka-UI primitive set under `src/components/ui/dialog/`. Migration
* tracked in `temp/plans/adr-0009-dialog-reka-migration-DRAFT.md`.
*/
type DialogRenderer = 'primevue' | 'reka'
@@ -201,7 +201,6 @@ export const useDialogStore = defineStore('dialog', () => {
closable: true,
closeOnEscape: true,
dismissableMask: true,
renderer: 'reka' as DialogRenderer,
...options.dialogComponentProps,
maximized: false,
onMaximize: () => {