Compare commits

..

1 Commits

Author SHA1 Message Date
Connor Byrne
7256ae081f fix: add explicit type parameters to useTemplateRef in LinearView
Add explicit type parameters to fix TS7022 errors during declaration
file generation where refs were implicitly typed as 'any'.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-19 15:58:41 -07:00
71 changed files with 337 additions and 3448 deletions

View File

@@ -1,62 +0,0 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

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

@@ -231,22 +231,6 @@ export class ExecutionHelper {
)
}
/** Send `execution_interrupted` WS event (user-initiated stop). */
executionInterrupted(jobId: string, nodeId: string): void {
this.requireWs().send(
JSON.stringify({
type: 'execution_interrupted',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
executed: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.requireWs().send(

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

@@ -1,138 +0,0 @@
import { expect } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
type CustomerBalanceResponse = NonNullable<
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
>
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const FUTURE_DATE = '2099-01-01T00:00:00Z'
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: FUTURE_DATE,
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
// in the credits row.
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
is_active: true,
subscription_id: 'sub_e2e',
renewal_date: FUTURE_DATE,
end_date: FUTURE_DATE
}
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
// of the popover before the fix.
const mockBalance: CustomerBalanceResponse = {
amount_micros: 3_000_000,
effective_balance_micros: 3_000_000,
currency: 'usd'
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await page.route('**/customers/cloud-subscription-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockSubscriptionStatus)
})
)
await page.route('**/customers/balance', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBalance)
})
)
await use(page)
}
})
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
test('keeps both action buttons inside the popover when cancelled but active', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
const addCredits = page.getByTestId('add-credits-button')
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
await expect(addCredits).toBeVisible()
await expect(resubscribe).toBeVisible()
const popoverBox = await popover.boundingBox()
const resubscribeBox = await resubscribe.boundingBox()
expect(popoverBox).not.toBeNull()
expect(resubscribeBox).not.toBeNull()
const popoverRight = popoverBox!.x + popoverBox!.width
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
})
})

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

@@ -1,139 +0,0 @@
import type { Locator, WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const KSAMPLER_NODE = '3'
async function runOnBackgroundTab(
comfyPage: ComfyPage,
ws: WebSocketRoute
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
const topbar = comfyPage.menu.topbar
await comfyPage.workflow.waitForActiveWorkflow()
await comfyPage.workflow.waitForWorkflowIdle()
const exec = new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
await comfyPage.nextFrame()
await topbar.newWorkflowButton.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(topbar.getActiveTab()).toContainText('(2)')
const backgroundTab = topbar.getTab(0)
exec.executionStart(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toBeVisible()
return { exec, jobId, backgroundTab }
}
test.describe('Workflow tab status indicator', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('replaces the running indicator with completed when the job finishes', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await expect(
backgroundTab.getByRole('img', { name: 'Running' })
).toHaveCount(0)
})
test('shows failed when the background job errors', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
// The error opens a modal dialog that aria-hides the rest of the app
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
// so the badge is reachable by role.
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
await expect(errorDialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(errorDialog).toBeHidden()
await expect(
backgroundTab.getByRole('img', { name: 'Failed' })
).toBeVisible()
})
test('drops the indicator on user interrupt rather than showing an error', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionInterrupted(jobId, KSAMPLER_NODE)
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
test('clears the indicator once the tab is activated', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
comfyPage,
ws
)
exec.executionSuccess(jobId)
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
const currentTab = comfyPage.menu.topbar.getActiveTab()
await expect(
backgroundTab.getByRole('img', { name: 'Completed' })
).toBeVisible()
await backgroundTab.click()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
await currentTab.click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
})
})

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

@@ -66,6 +66,7 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -194,15 +195,20 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const jobId = item.taskRef?.jobId
if (!jobId) return
if (
item.state === 'running' ||
item.state === 'initialization' ||
item.state === 'pending'
) {
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
await api.cancelJob(jobId)
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
// Cloud backend uses deleteItem, local uses interrupt
if (isCloud) {
await api.deleteItem('queue', jobId)
} else {
await api.interrupt(jobId)
}
executionStore.clearInitializationByJobId(jobId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
await api.deleteItem('queue', jobId)
await queueStore.update()
}
})
@@ -286,8 +292,17 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
if (!jobIds.length) return
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
await api.cancelJobs(jobIds)
// Cloud backend supports cancelling specific jobs via /queue delete,
// while /interrupt always targets the "first" job. Use the targeted API
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByJobIds(jobIds)
await queueStore.update()
return
}
await Promise.all(jobIds.map((id) => api.interrupt(id)))
executionStore.clearInitializationByJobIds(jobIds)
await queueStore.update()
})

View File

@@ -1,233 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { markRaw } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import type * as ExecutionStoreModule from '@/stores/executionStore'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockWorkflowStatus: shallowRef<Map<object, WorkflowExecutionStatus>>(
new Map()
),
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
}
})
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isLoading: false
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isInitialized: true
})
}))
vi.mock('@/stores/executionStore', async (importOriginal) => {
const actual = await importOriginal<typeof ExecutionStoreModule>()
return {
WORKFLOW_STATUS_I18N_KEYS: actual.WORKFLOW_STATUS_I18N_KEYS,
useExecutionStore: () => ({
getWorkflowStatus(workflow: object | undefined | null) {
if (!workflow) return undefined
return mockWorkflowStatus.value.get(workflow)
}
})
}
})
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDraggable: vi.fn(),
usePragmaticDroppable: vi.fn()
}))
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
useWorkflowActionsMenu: () => ({
menuItems: { value: [] }
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
closeWorkflow: mockCloseWorkflow
})
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
getThumbnail: vi.fn(() => null)
})
}))
vi.mock('./WorkflowTabPopover.vue', () => ({
default: {
render: () => null,
methods: {
showPopover: () => {},
hidePopover: () => {},
togglePopover: () => {}
}
}
}))
import WorkflowTab from './WorkflowTab.vue'
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
const statusAriaLabels: Record<WorkflowExecutionStatus, string> = {
running: 'Running',
completed: 'Completed',
failed: 'Failed'
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', ...statusAriaLabels }
}
}
})
type WorkflowOption = WorkflowTabProps['workflowOption']
type Workflow = WorkflowOption['workflow']
type WorkflowOverrides = Partial<Workflow>
// ComfyWorkflow has many required fields the component never reads (file
// IO, change tracking). Validate the fields we *do* set against the real
// type via Partial<Workflow>, then cast — adding/renaming a read field in
// the component will fail typecheck on the override map.
function makeWorkflowOption(overrides: WorkflowOverrides = {}): WorkflowOption {
const workflow = {
key: 'test-key',
path: '/workflows/test.json',
filename: 'test.json',
isPersisted: true,
isModified: false,
activeMode: 'graph',
changeTracker: null,
...overrides
} satisfies WorkflowOverrides
// markRaw keeps a stable identity through prop reactivity so the store's
// identity-based status lookup resolves against the same object.
return { value: 'test-key', workflow: markRaw(workflow) as Workflow }
}
function renderTab({
workflowOption = makeWorkflowOption(),
activeWorkflowKey = 'other-key'
}: {
workflowOption?: WorkflowOption
activeWorkflowKey?: string
} = {}) {
return render(WorkflowTab, {
global: {
plugins: [
createTestingPinia({
stubActions: false,
initialState: {
workspace: { shiftDown: false },
workflow: {
activeWorkflow: { key: activeWorkflowKey }
},
setting: { settingValues: { 'Comfy.Workflow.AutoSave': 'off' } }
}
}),
i18n
],
stubs: {
WorkflowActionsList: true,
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
},
props: {
workflowOption,
isFirst: false,
isLast: false
}
})
}
describe('WorkflowTab - workflow status indicator', () => {
beforeEach(() => {
mockWorkflowStatus.value = new Map()
})
it.for(['running', 'completed', 'failed'] as const)(
'labels the %s indicator with a translated status name',
(status) => {
const workflowOption = makeWorkflowOption()
mockWorkflowStatus.value = new Map([[workflowOption.workflow, status]])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels[status] })
).toBeTruthy()
}
)
it('does not badge the active tab with its own status', () => {
const workflowOption = makeWorkflowOption()
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
renderTab({ workflowOption, activeWorkflowKey: 'test-key' })
expect(screen.queryByRole('img')).toBeNull()
})
it('shows unsaved dot when no workflow status and workflow is unsaved', () => {
renderTab({ workflowOption: makeWorkflowOption({ isPersisted: false }) })
expect(screen.queryByRole('img')).toBeNull()
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('shows the unsaved dot when modified and autosave is off', () => {
renderTab({ workflowOption: makeWorkflowOption({ isModified: true }) })
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
})
it('workflow status replaces the unsaved dot', () => {
const workflowOption = makeWorkflowOption({ isPersisted: false })
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
renderTab({ workflowOption })
expect(
screen.getByRole('img', { name: statusAriaLabels.running })
).toBeTruthy()
expect(screen.queryByTestId('workflow-dirty-indicator')).toBeNull()
})
})
describe('WorkflowTab - close button', () => {
beforeEach(() => {
mockCloseWorkflow.mockClear()
})
it('delegates close to workflow service with the tab workflow', async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByTestId('close-workflow-button'))
expect(mockCloseWorkflow).toHaveBeenCalledWith(
expect.objectContaining({ key: 'test-key' }),
expect.anything()
)
})
})

View File

@@ -21,19 +21,8 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<i
v-if="workflowStatus"
role="img"
:aria-label="workflowStatusLabel"
:class="
cn(
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
workflowStatusIconClasses[workflowStatus]
)
"
/>
<span
v-else-if="shouldShowUnsavedIndicator"
v-if="shouldShowStatusIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
@@ -43,7 +32,6 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
data-testid="close-workflow-button"
@click.stop="onCloseWorkflow(workflowOption)"
>
<i class="pi pi-times" />
@@ -97,14 +85,8 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
import {
useExecutionStore,
WORKFLOW_STATUS_I18N_KEYS
} from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { cn } from '@comfyorg/tailwind-utils'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -131,7 +113,6 @@ const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
@@ -144,7 +125,7 @@ const autoSaveDelay = computed(() =>
settingStore.get('Comfy.Workflow.AutoSaveDelay')
)
const shouldShowUnsavedIndicator = computed(() => {
const shouldShowStatusIndicator = computed(() => {
if (workspaceStore.shiftDown) {
// Branch 1: Shift key is held down, do not show the status indicator.
return false
@@ -179,27 +160,6 @@ const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const workflowStatusIconClasses: Record<WorkflowExecutionStatus, string> = {
running:
'text-base-foreground icon-[lucide--loader-circle] motion-safe:animate-spin',
completed: 'icon-[lucide--circle-check] text-success-background',
failed: 'icon-[lucide--octagon-alert] text-destructive-background'
}
// The active tab doesn't badge its own status - the user is already looking
// at it. Background tabs surface the recorded execution status.
const workflowStatus = computed(() =>
isActiveTab.value
? undefined
: executionStore.getWorkflowStatus(props.workflowOption.workflow)
)
const workflowStatusLabel = computed(() =>
workflowStatus.value
? t(WORKFLOW_STATUS_I18N_KEYS[workflowStatus.value])
: undefined
)
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})

View File

@@ -43,10 +43,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
}))
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
useWorkflowStatusDismissal: vi.fn()
}))
vi.mock('@/composables/element/useOverflowObserver', () => ({
useOverflowObserver: () => ({
isOverflowing: { value: false },

View File

@@ -117,7 +117,6 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
@@ -146,9 +145,6 @@ const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
// Dismiss a tab's terminal status badge once it has been viewed
useWorkflowStatusDismissal()
const { flags } = useFeatureFlags()
const isIntegratedTabBar = computed(

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

@@ -68,10 +68,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: class {}
}))
const cancelJobMock = vi.fn()
const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
cancelJob: (jobId: string) => cancelJobMock(jobId)
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
}
}))
@@ -195,7 +197,6 @@ describe('useJobMenu', () => {
}))
queueStoreMock.update.mockResolvedValue(undefined)
queueStoreMock.delete.mockResolvedValue(undefined)
cancelJobMock.mockResolvedValue(undefined)
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
task,
@@ -280,18 +281,29 @@ describe('useJobMenu', () => {
expect(copyToClipboardMock).not.toHaveBeenCalled()
})
it.for([['running'], ['initialization'], ['pending']])(
'cancels %s job via the state-agnostic jobs-namespace endpoint',
async ([state]) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
it.for([
['running', interruptMock, deleteItemMock],
['initialization', interruptMock, deleteItemMock]
])('cancels %s job via interrupt', async ([state]) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
await cancelJob()
await cancelJob()
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
}
)
expect(interruptMock).toHaveBeenCalledWith('job-1')
expect(deleteItemMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('cancels pending job via deleteItem', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
await cancelJob()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('still updates queue for uncancellable states', async () => {
const { cancelJob } = mountJobMenu()
@@ -299,22 +311,11 @@ describe('useJobMenu', () => {
await cancelJob()
expect(cancelJobMock).not.toHaveBeenCalled()
expect(interruptMock).not.toHaveBeenCalled()
expect(deleteItemMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('propagates cancel failures from the API', async () => {
cancelJobMock.mockRejectedValueOnce(new Error('Failed to cancel job'))
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'running' }))
await expect(cancelJob()).rejects.toThrow('Failed to cancel job')
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
// Queue refresh is skipped when the cancel request itself fails.
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it('copies error message from failed job entry', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
@@ -859,7 +860,7 @@ describe('useJobMenu', () => {
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
await cancelEntry?.onClick?.()
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
})

View File

@@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { st, t } from '@/i18n'
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -82,13 +83,14 @@ export function useJobMenu(
const cancelJob = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
if (
target.state === 'running' ||
target.state === 'initialization' ||
target.state === 'pending'
) {
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
await api.cancelJob(target.id)
if (target.state === 'running' || target.state === 'initialization') {
if (isCloud) {
await api.deleteItem('queue', target.id)
} else {
await api.interrupt(target.id)
}
} else if (target.state === 'pending') {
await api.deleteItem('queue', target.id)
}
executionStore.clearInitializationByJobId(target.id)
await queueStore.update()

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

@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick } from 'vue'
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
const { mockActiveWorkflow, statusMap } = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockActiveWorkflow: shallowRef<object | null>(null),
statusMap: shallowRef<Map<object, WorkflowExecutionStatus>>(new Map())
}
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
getWorkflowStatus: (workflow: object | null | undefined) =>
workflow ? statusMap.value.get(workflow) : undefined,
clearWorkflowStatus: (workflow: object) => {
const next = new Map(statusMap.value)
next.delete(workflow)
statusMap.value = next
}
})
}))
import { useWorkflowStatusDismissal } from './useWorkflowStatusDismissal'
const workflowA = { path: '/a.json' }
const workflowB = { path: '/b.json' }
function mount() {
const scope = effectScope()
scope.run(() => useWorkflowStatusDismissal())
return () => scope.stop()
}
describe('useWorkflowStatusDismissal', () => {
beforeEach(() => {
mockActiveWorkflow.value = null
statusMap.value = new Map()
})
it('clears a terminal status when its workflow becomes active', async () => {
statusMap.value = new Map([[workflowA, 'completed']])
const stop = mount()
mockActiveWorkflow.value = workflowA
await nextTick()
expect(statusMap.value.has(workflowA)).toBe(false)
stop()
})
it('clears a terminal status that arrives while the workflow is active', async () => {
mockActiveWorkflow.value = workflowA
const stop = mount()
statusMap.value = new Map([[workflowA, 'failed']])
await nextTick()
expect(statusMap.value.has(workflowA)).toBe(false)
stop()
})
it('keeps a running status on the active workflow', async () => {
mockActiveWorkflow.value = workflowA
const stop = mount()
statusMap.value = new Map([[workflowA, 'running']])
await nextTick()
expect(statusMap.value.get(workflowA)).toBe('running')
stop()
})
it('leaves other workflows untouched', async () => {
statusMap.value = new Map([
[workflowA, 'completed'],
[workflowB, 'completed']
])
const stop = mount()
mockActiveWorkflow.value = workflowA
await nextTick()
expect(statusMap.value.has(workflowA)).toBe(false)
expect(statusMap.value.get(workflowB)).toBe('completed')
stop()
})
})

View File

@@ -1,22 +0,0 @@
import { watch } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
export function useWorkflowStatusDismissal() {
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
executionStore.clearWorkflowStatus(workflow)
}
},
{ immediate: true }
)
}

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

@@ -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

@@ -2474,19 +2474,6 @@
"confirmCancel": "Cancel subscription",
"failed": "Failed to cancel subscription"
},
"downgrade": {
"title": "Change to {plan} plan?",
"body": "All other members of this workspace will be immediately removed.",
"confirmationPhrase": "I understand",
"confirmationPrompt": "Type \"{phrase}\" to confirm.",
"confirm": "Change plan",
"failed": "Failed to change plan",
"notAllowed": "This plan change is not available",
"paymentMethodRequired": "A payment method is required to change plans",
"paymentPageBlocked": "Couldn't open the payment page — please try again",
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
},
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
@@ -3353,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'
@@ -132,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

@@ -1,7 +1,6 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useDialogService } from '@/services/dialogService'
import type { DialogComponentProps } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
interface ShowOptions {
@@ -24,10 +23,6 @@ interface BrowseOptions {
}
const DIALOG_KEY = 'global-asset-browser'
const ASSET_BROWSER_DIALOG_PROPS = {
contentClass:
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-none bg-transparent shadow-none'
} satisfies DialogComponentProps
export const useAssetBrowserDialog = () => {
const dialogService = useDialogService()
@@ -52,8 +47,7 @@ export const useAssetBrowserDialog = () => {
currentValue: props.currentValue,
onSelect: handleAssetSelected,
onClose: hide
},
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
}
})
}
@@ -72,8 +66,7 @@ export const useAssetBrowserDialog = () => {
title: options.title,
onSelect: handleAssetSelected,
onClose: hide
},
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
}
})
}

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

@@ -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

@@ -1,7 +1,7 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
class="current-user-popover -m-3 w-fit max-w-96 min-w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="mb-4 flex flex-col items-center px-0 py-3">
@@ -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

@@ -1,122 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import DowngradeRemoveMembersDialogContent from './DowngradeRemoveMembersDialogContent.vue'
const mockCloseDialog = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: mockCloseDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {
g: { cancel: 'Cancel', close: 'Close', unknownError: 'Unknown error' },
subscription: {
downgrade: {
title: 'Change to {plan} plan?',
body: 'All other members of this workspace will be immediately removed.',
confirmationPhrase: 'I understand',
confirmationPrompt: 'Type "{phrase}" to confirm.',
confirm: 'Change plan',
failed: 'Failed to change plan'
}
}
}
}
})
function mountComponent(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(DowngradeRemoveMembersDialogContent, {
props: {
planName: 'Founder',
planSlug: 'founder-monthly',
onConfirm,
...props
},
global: {
plugins: [i18n]
}
})
return { user, onConfirm }
}
const getPhraseInput = () => screen.getByRole('textbox')
const getChangePlanButton = () =>
screen.getByRole('button', { name: 'Change plan' })
const getCancelButton = () => screen.getByRole('button', { name: 'Cancel' })
describe('DowngradeRemoveMembersDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('disables Change plan until the exact phrase is typed', async () => {
const { user } = mountComponent()
expect(getChangePlanButton()).toBeDisabled()
await user.type(getPhraseInput(), 'I understan')
expect(getChangePlanButton()).toBeDisabled()
await user.type(getPhraseInput(), 'd')
expect(getChangePlanButton()).toBeEnabled()
})
it('keeps Change plan disabled for a case-mismatched phrase', async () => {
const { user } = mountComponent()
await user.type(getPhraseInput(), 'i understand')
expect(getChangePlanButton()).toBeDisabled()
})
it('invokes onConfirm with the plan slug and closes when confirmed', async () => {
const { user, onConfirm } = mountComponent()
await user.type(getPhraseInput(), 'I understand')
await user.click(getChangePlanButton())
expect(onConfirm).toHaveBeenCalledWith('founder-monthly')
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'downgrade-remove-members'
})
})
it('closes without calling onConfirm when cancelled', async () => {
const { user, onConfirm } = mountComponent()
await user.type(getPhraseInput(), 'I understand')
await user.click(getCancelButton())
expect(onConfirm).not.toHaveBeenCalled()
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'downgrade-remove-members'
})
})
it('shows an error toast and stays open when onConfirm rejects', async () => {
const onConfirm = vi.fn().mockRejectedValue(new Error('boom'))
const { user } = mountComponent({ onConfirm })
await user.type(getPhraseInput(), 'I understand')
await user.click(getChangePlanButton())
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(mockCloseDialog).not.toHaveBeenCalled()
})
})

View File

@@ -1,105 +0,0 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('subscription.downgrade.title', { plan: planName }) }}
</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
:disabled="isLoading"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 p-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('subscription.downgrade.body') }}
</p>
<label class="flex flex-col gap-2 text-sm text-muted-foreground">
{{ $t('subscription.downgrade.confirmationPrompt', { phrase }) }}
<Input
v-model="typedValue"
type="text"
:placeholder="phrase"
:disabled="isLoading"
autofocus
@keyup.enter="onConfirmDowngrade"
/>
</label>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 p-4">
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
{{ $t('g.cancel') }}
</Button>
<Button
variant="destructive"
size="lg"
:disabled="!isConfirmed"
:loading="isLoading"
@click="onConfirmDowngrade"
>
{{ $t('subscription.downgrade.confirm') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { planName, planSlug, onConfirm } = defineProps<{
planName: string
planSlug: string
onConfirm: (planSlug: string) => Promise<void>
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const phrase = t('subscription.downgrade.confirmationPhrase')
const typedValue = ref('')
const isLoading = ref(false)
const isConfirmed = computed(() => typedValue.value === phrase)
function onClose() {
if (isLoading.value) return
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
}
async function onConfirmDowngrade() {
if (!isConfirmed.value || isLoading.value) return
isLoading.value = true
try {
await onConfirm(planSlug)
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
} catch (error) {
toast.add({
severity: 'error',
summary: t('subscription.downgrade.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
isLoading.value = false
}
}
</script>

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

@@ -1,348 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDowngradeToPersonal } from './useDowngradeToPersonal'
const mockMembers = ref<WorkspaceMember[]>([])
const mockUserEmail = ref<string | null>(null)
const mockRemoveMember = vi.hoisted(() => vi.fn())
const mockFetchMembers = vi.hoisted(() => vi.fn())
const mockSubscribe = vi.hoisted(() => vi.fn())
const mockPreviewSubscribe = vi.hoisted(() => vi.fn())
const mockStartOperation = vi.hoisted(() => vi.fn())
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
members: mockMembers,
removeMember: mockRemoveMember,
fetchMembers: mockFetchMembers
})
}))
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
startOperation: mockStartOperation
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
subscribe: mockSubscribe,
previewSubscribe: mockPreviewSubscribe
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userEmail: mockUserEmail
})
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key} ${JSON.stringify(params)}` : key
}))
vi.mock('@/config/comfyApi', () => ({
getComfyPlatformBaseUrl: () => 'https://platform.test'
}))
function createMember(
overrides: Partial<WorkspaceMember> = {}
): WorkspaceMember {
return {
id: 'member-1',
name: 'Member One',
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}
function teamWithOwnerAnd(...memberIds: string[]) {
return [
createMember({
id: 'owner',
role: 'owner',
email: 'owner@example.com',
isOriginalOwner: true
}),
...memberIds.map((id) => createMember({ id, email: `${id}@example.com` }))
]
}
describe('useDowngradeToPersonal', () => {
let windowOpen: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.resetAllMocks()
mockMembers.value = []
mockUserEmail.value = null
mockPreviewSubscribe.mockResolvedValue({ allowed: true })
mockSubscribe.mockResolvedValue({
billing_op_id: 'op-1',
status: 'subscribed'
})
windowOpen = vi.spyOn(window, 'open').mockReturnValue({} as Window)
})
afterEach(() => {
windowOpen.mockRestore()
})
describe('removableMembers / hasOtherMembers', () => {
it('protects only the original owner, removing promoted owners and members', () => {
mockMembers.value = [
createMember({ id: 'creator', role: 'owner', isOriginalOwner: true }),
createMember({
id: 'promoted-owner',
role: 'owner',
isOriginalOwner: false
}),
createMember({ id: 'member', role: 'member', isOriginalOwner: false })
]
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
expect(removableMembers.value.map((m) => m.id)).toEqual([
'promoted-owner',
'member'
])
expect(hasOtherMembers.value).toBe(true)
})
it('reports no other members when only the original owner is present', () => {
mockMembers.value = teamWithOwnerAnd()
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
expect(removableMembers.value).toEqual([])
expect(hasOtherMembers.value).toBe(false)
})
it('falls back to protecting owners and the current user when the flag is absent', () => {
mockUserEmail.value = 'me@example.com'
mockMembers.value = [
createMember({
id: 'owner',
role: 'owner',
email: 'owner@example.com',
isOriginalOwner: false
}),
createMember({
id: 'me',
role: 'member',
email: 'me@example.com',
isOriginalOwner: false
}),
createMember({
id: 'plain',
role: 'member',
email: 'plain@example.com',
isOriginalOwner: false
})
]
const { removableMembers } = useDowngradeToPersonal()
expect(removableMembers.value.map((m) => m.id)).toEqual(['plain'])
})
})
describe('downgradeToPersonal', () => {
it('removes every non-creator member then initiates the tier change', async () => {
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
const { downgradeToPersonal } = useDowngradeToPersonal()
await downgradeToPersonal('founder-monthly')
expect(mockRemoveMember).toHaveBeenCalledTimes(2)
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
expect(mockRemoveMember).toHaveBeenCalledWith('m2')
expect(mockRemoveMember).not.toHaveBeenCalledWith('owner')
expect(mockSubscribe).toHaveBeenCalledWith(
'founder-monthly',
'https://platform.test/payment/success',
'https://platform.test/payment/failed'
)
expect(mockStartOperation).not.toHaveBeenCalled()
})
it('never removes the original owner', async () => {
mockMembers.value = [
createMember({ id: 'me', role: 'owner', isOriginalOwner: true })
]
const { downgradeToPersonal } = useDowngradeToPersonal()
await downgradeToPersonal('founder-monthly')
expect(mockRemoveMember).not.toHaveBeenCalled()
expect(mockSubscribe).toHaveBeenCalled()
})
it('validates the transition before removing, then removes, then subscribes', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
const calls: string[] = []
mockPreviewSubscribe.mockImplementation(() => {
calls.push('preview')
return Promise.resolve({ allowed: true })
})
mockRemoveMember.mockImplementation(() => {
calls.push('remove')
return Promise.resolve()
})
mockSubscribe.mockImplementation(() => {
calls.push('subscribe')
return Promise.resolve({ billing_op_id: 'op-1', status: 'subscribed' })
})
const { downgradeToPersonal } = useDowngradeToPersonal()
await downgradeToPersonal('founder-monthly')
expect(calls).toEqual(['preview', 'remove', 'subscribe'])
})
it('throws the BE reason and removes nobody when the transition is disallowed', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockPreviewSubscribe.mockResolvedValue({
allowed: false,
reason: 'Outstanding balance'
})
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'Outstanding balance'
)
expect(mockRemoveMember).not.toHaveBeenCalled()
expect(mockSubscribe).not.toHaveBeenCalled()
})
it('opens the payment-method page and polls when subscribe needs a payment method', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockSubscribe.mockResolvedValue({
billing_op_id: 'op-2',
status: 'needs_payment_method',
payment_method_url: 'https://pay.test/method'
})
const { downgradeToPersonal } = useDowngradeToPersonal()
await downgradeToPersonal('founder-monthly')
expect(windowOpen).toHaveBeenCalledWith(
'https://pay.test/method',
'_blank'
)
expect(mockStartOperation).toHaveBeenCalledWith('op-2', 'subscription')
})
it('falls back to the generic message when the transition is disallowed without a reason', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockPreviewSubscribe.mockResolvedValue({ allowed: false })
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'subscription.downgrade.notAllowed'
)
})
it('throws and skips polling when the payment tab is popup-blocked', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockSubscribe.mockResolvedValue({
billing_op_id: 'op-5',
status: 'needs_payment_method',
payment_method_url: 'https://pay.test/method'
})
windowOpen.mockReturnValue(null)
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'subscription.downgrade.paymentPageBlocked'
)
expect(mockStartOperation).not.toHaveBeenCalled()
})
it('throws when a payment method is needed but no url is provided', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockSubscribe.mockResolvedValue({
billing_op_id: 'op-3',
status: 'needs_payment_method'
})
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'subscription.downgrade.paymentMethodRequired'
)
expect(mockStartOperation).not.toHaveBeenCalled()
})
it('polls without opening a tab when the payment is pending', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockSubscribe.mockResolvedValue({
billing_op_id: 'op-4',
status: 'pending_payment'
})
const { downgradeToPersonal } = useDowngradeToPersonal()
await downgradeToPersonal('founder-monthly')
expect(windowOpen).not.toHaveBeenCalled()
expect(mockStartOperation).toHaveBeenCalledWith('op-4', 'subscription')
})
it('reports the generic failure when subscribe fails and no members were removed', async () => {
mockMembers.value = teamWithOwnerAnd()
mockSubscribe.mockResolvedValue(undefined)
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
/^subscription\.downgrade\.failed$/
)
})
it('reports members were already removed when subscribe fails after removal', async () => {
mockMembers.value = teamWithOwnerAnd('m1')
mockSubscribe.mockResolvedValue(undefined)
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'subscription.downgrade.failedAfterMemberRemoval'
)
})
it('surfaces which member failed and skips the plan change when removal throws', async () => {
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
mockRemoveMember.mockImplementation((id: string) =>
id === 'm2' ? Promise.reject(new Error('network')) : Promise.resolve()
)
const { downgradeToPersonal } = useDowngradeToPersonal()
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
'm2@example.com'
)
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
expect(mockSubscribe).not.toHaveBeenCalled()
})
})
describe('refreshMembers', () => {
it('refetches members so a stale empty list cannot skip the confirm gate', async () => {
mockMembers.value = []
mockFetchMembers.mockImplementation(() => {
mockMembers.value = teamWithOwnerAnd('m1')
return Promise.resolve(mockMembers.value)
})
const { refreshMembers, hasOtherMembers } = useDowngradeToPersonal()
expect(hasOtherMembers.value).toBe(false)
await refreshMembers()
expect(hasOtherMembers.value).toBe(true)
})
})
})

View File

@@ -1,101 +0,0 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
/**
* Team-plan downgrade to personal: validate via `previewSubscribe`, remove
* every member except the original owner, then initiate the tier change.
* BE seam (BE-1337): removal email and an atomic downgrade endpoint are
* BE-owned; until then the FE orchestrates the two steps non-atomically.
*/
export function useDowngradeToPersonal() {
const workspaceStore = useTeamWorkspaceStore()
const { members } = storeToRefs(workspaceStore)
const { subscribe, previewSubscribe } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const { userEmail } = useCurrentUser()
const removableMembers = computed(() => {
const hasFlag = members.value.some((m) => m.isOriginalOwner)
if (hasFlag) return members.value.filter((m) => !m.isOriginalOwner)
const email = userEmail.value?.toLowerCase() ?? null
return members.value.filter(
(m) => m.role !== 'owner' && m.email.toLowerCase() !== email
)
})
const hasOtherMembers = computed(() => removableMembers.value.length > 0)
async function refreshMembers(): Promise<void> {
await workspaceStore.fetchMembers()
}
async function downgradeToPersonal(planSlug: string): Promise<void> {
const preview = await previewSubscribe(planSlug)
if (!preview?.allowed) {
throw new Error(preview?.reason || t('subscription.downgrade.notAllowed'))
}
const membersToRemove = removableMembers.value
for (const member of membersToRemove) {
try {
await workspaceStore.removeMember(member.id)
} catch (error) {
throw new Error(
t('subscription.downgrade.memberRemovalFailed', {
email: member.email
}),
{ cause: error }
)
}
}
const response = await subscribe(
planSlug,
`${getComfyPlatformBaseUrl()}/payment/success`,
`${getComfyPlatformBaseUrl()}/payment/failed`
)
if (!response) {
throw new Error(
membersToRemove.length > 0
? t('subscription.downgrade.failedAfterMemberRemoval')
: t('subscription.downgrade.failed')
)
}
if (response.status === 'needs_payment_method') {
if (!response.payment_method_url) {
throw new Error(t('subscription.downgrade.paymentMethodRequired'))
}
const paymentTab = window.open(response.payment_method_url, '_blank')
if (!paymentTab) {
throw new Error(t('subscription.downgrade.paymentPageBlocked'))
}
void billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
return
}
if (response.status === 'pending_payment') {
void billingOperationStore.startOperation(
response.billing_op_id,
'subscription'
)
}
}
return {
removableMembers,
hasOtherMembers,
refreshMembers,
downgradeToPersonal
}
}

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

@@ -274,27 +274,6 @@ describe('useWorkspaceBilling', () => {
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
})
it('returns the successful response when the post-subscribe refresh fails', async () => {
mockWorkspaceApi.subscribe.mockResolvedValue({
billing_op_id: 'op-1',
status: 'subscribed'
})
mockWorkspaceApi.getBillingStatus.mockRejectedValue(
new Error('refresh down')
)
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
const billing = setupBilling()
await expect(billing.subscribe('pro')).resolves.toStrictEqual({
billing_op_id: 'op-1',
status: 'subscribed'
})
expect(billing.error.value).toBe(
'Subscription succeeded, but billing state refresh failed'
)
})
it('propagates error and records message when subscribe fails', async () => {
mockWorkspaceApi.subscribe.mockRejectedValue(new Error('denied'))

View File

@@ -146,18 +146,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
cancelUrl
)
// Refresh is non-fatal: the subscribe write already succeeded, so a failed
// refresh must not reject and prompt a retry of an active subscription.
const [statusResult, balanceResult] = await Promise.allSettled([
fetchStatus(),
fetchBalance()
])
if (
statusResult.status === 'rejected' ||
balanceResult.status === 'rejected'
) {
error.value = 'Subscription succeeded, but billing state refresh failed'
}
// Refresh status and balance after subscription
await Promise.all([fetchStatus(), fetchBalance()])
return response
} catch (err) {

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

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, provide, ref, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -151,17 +150,6 @@ function handleIsOpenUpdate(isOpen: boolean) {
void outputMediaAssets.refresh()
}
}
const handleApproachEnd = useDebounceFn(async () => {
if (
outputMediaAssets.hasMore.value &&
!outputMediaAssets.loading.value &&
!outputMediaAssets.isLoadingMore.value
) {
await outputMediaAssets.loadMore()
}
}, 300)
const isUploading = ref(false)
async function updateFiles(files: File[]) {
isUploading.value = true
@@ -191,12 +179,10 @@ async function updateFiles(files: File[]) {
:base-model-options
:is-uploading
v-bind="combinedProps"
:loading-more="outputMediaAssets.isLoadingMore.value"
class="w-full"
@update:selected="updateSelectedItems"
@update:files="updateFiles"
@update:is-open="handleIsOpenUpdate"
@approach-end="handleApproachEnd"
/>
</WidgetLayoutField>
</template>

View File

@@ -37,7 +37,6 @@ interface Props {
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
loadingMore?: boolean
isSelected?: (
selected: Set<string>,
item: FormDropdownItem,
@@ -65,16 +64,11 @@ const {
ownershipOptions,
showBaseModelFilter,
baseModelOptions,
loadingMore = false,
isSelected = (selected, item, _index) => selected.has(item.id),
searcher = defaultSearcher,
items
} = defineProps<Props>()
const emit = defineEmits<{
(e: 'approach-end'): void
}>()
const placeholderText = computed(
() => placeholder ?? t('widgets.uploadSelect.placeholder')
)
@@ -322,11 +316,9 @@ function handleSearchEnter() {
:candidate-label
:is-selected="internalIsSelected"
:max-selectable
:loading-more="loadingMore"
@close="closeDropdown"
@search-enter="handleSearchEnter"
@item-click="handleSelection"
@approach-end="emit('approach-end')"
/>
</Popover>
</div>

View File

@@ -1,4 +1,3 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
@@ -8,9 +7,8 @@ import type { FormDropdownItem, LayoutMode } from './types'
const VirtualGridStub = {
name: 'VirtualGrid',
props: ['items', 'maxColumns', 'itemHeight', 'scrollerHeight'],
emits: ['approach-end'],
template:
'<div data-testid="virtual-grid" :data-items="JSON.stringify(items)" :data-max-columns="maxColumns" @click="$emit(\'approach-end\')" />'
'<div data-testid="virtual-grid" :data-items="JSON.stringify(items)" :data-max-columns="maxColumns" />'
}
function createItem(id: string, name: string): FormDropdownItem {
@@ -95,31 +93,6 @@ describe('FormDropdownMenu', () => {
expect(virtualGrid.getAttribute('data-max-columns')).toBe('1')
})
it('forwards approach-end from the virtual grid', async () => {
const user = userEvent.setup()
const { emitted } = render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
await user.click(screen.getByTestId('virtual-grid'))
expect(emitted()['approach-end']).toHaveLength(1)
})
it('shows the loading-more row only while loadingMore is set', async () => {
const { rerender } = render(FormDropdownMenu, {
props: { ...defaultProps, loadingMore: true },
global: globalConfig
})
expect(screen.getByTestId('form-dropdown-loading-more')).toBeTruthy()
await rerender({ ...defaultProps, loadingMore: false })
expect(screen.queryByTestId('form-dropdown-loading-more')).toBeNull()
})
it('has data-capture-wheel="true" on the root element', () => {
render(FormDropdownMenu, {
props: defaultProps,

View File

@@ -27,7 +27,6 @@ interface Props {
baseModelOptions?: FilterOption[]
candidateIndex?: number
candidateLabel?: string
loadingMore?: boolean
}
const {
@@ -40,13 +39,11 @@ const {
showBaseModelFilter,
baseModelOptions,
candidateIndex = -1,
candidateLabel,
loadingMore = false
candidateLabel
} = defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
(e: 'search-enter'): void
(e: 'approach-end'): void
}>()
const filterSelected = defineModel<string>('filterSelected')
@@ -161,7 +158,6 @@ const onWheel = (event: WheelEvent) => {
:default-item-width="layoutConfig.itemWidth"
:buffer-rows="2"
class="mt-2 min-h-0 flex-1"
@approach-end="emit('approach-end')"
>
<template #item="{ item, index }">
<FormDropdownMenuItem
@@ -176,15 +172,5 @@ const onWheel = (event: WheelEvent) => {
/>
</template>
</VirtualGrid>
<div
v-if="loadingMore"
class="flex items-center justify-center py-2"
data-testid="form-dropdown-loading-more"
>
<i
:aria-label="$t('g.loading')"
class="icon-[lucide--loader] size-6 animate-spin text-muted-foreground"
/>
</div>
</div>
</template>

View File

@@ -1,94 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
// Tests for api.cancelJob and api.cancelJobs; fetchApi is stubbed.
const okResponse = () => ({ ok: true, status: 200 }) as Response
const errorResponse = (status: number, body = '') =>
({
ok: false,
status,
text: () => Promise.resolve(body)
}) as unknown as Response
describe('api jobs-namespace cancel', () => {
let fetchApiSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
fetchApiSpy = vi.spyOn(api, 'fetchApi').mockResolvedValue(okResponse())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('cancelJob (single)', () => {
it('POSTs to the single-job cancel endpoint', async () => {
await api.cancelJob('abc-123')
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/abc-123/cancel', {
method: 'POST'
})
})
it('encodes the job id in the path', async () => {
await api.cancelJob('a/b c')
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/a%2Fb%20c/cancel', {
method: 'POST'
})
})
it('throws when the request fails', async () => {
fetchApiSpy.mockResolvedValueOnce(errorResponse(500))
await expect(api.cancelJob('abc-123')).rejects.toThrow(
'Failed to cancel job abc-123: 500'
)
})
it('includes the response body in the error when present', async () => {
fetchApiSpy.mockResolvedValueOnce(errorResponse(404, 'job not found'))
await expect(api.cancelJob('abc-123')).rejects.toThrow(
'Failed to cancel job abc-123: 404 — job not found'
)
})
})
describe('cancelJobs (batch)', () => {
it('POSTs the job_ids array to the batch cancel endpoint', async () => {
await api.cancelJobs(['id-1', 'id-2'])
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ job_ids: ['id-1', 'id-2'] })
})
})
it('does not call the API for an empty list', async () => {
await api.cancelJobs([])
expect(fetchApiSpy).not.toHaveBeenCalled()
})
it('throws when the batch request fails', async () => {
fetchApiSpy.mockResolvedValueOnce(errorResponse(500))
await expect(api.cancelJobs(['id-1'])).rejects.toThrow(
'Failed to cancel jobs: 500'
)
})
it('includes the response body in the error when present', async () => {
fetchApiSpy.mockResolvedValueOnce(errorResponse(422, 'invalid job ids'))
await expect(api.cancelJobs(['id-1'])).rejects.toThrow(
'Failed to cancel jobs: 422 — invalid job ids'
)
})
})
})

View File

@@ -1110,52 +1110,6 @@ export class ComfyApi extends EventTarget {
)
}
/**
* Cancels a single job by id via `POST /api/jobs/{job_id}/cancel` (idempotent:
* already-terminal jobs are a no-op). Requires runtime parity — not every
* runtime exposes this endpoint yet; do not merge callers before parity lands.
*
* @param {string} jobId The id of the job to cancel
*/
async cancelJob(jobId: string) {
const res = await this.fetchApi(
`/jobs/${encodeURIComponent(jobId)}/cancel`,
{
method: 'POST'
}
)
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(
`Failed to cancel job ${jobId}: ${res.status}${body ? `${body}` : ''}`
)
}
}
/**
* Cancels multiple jobs in a single request via `POST /api/jobs/cancel` with
* body `{ job_ids: [...] }`. Already-terminal jobs are no-ops. Same runtime
* parity requirement as {@link cancelJob}.
*
* @param {string[]} jobIds The ids of the jobs to cancel
*/
async cancelJobs(jobIds: string[]) {
if (!jobIds.length) return
const res = await this.fetchApi('/jobs/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ job_ids: jobIds })
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(
`Failed to cancel jobs: ${res.status}${body ? `${body}` : ''}`
)
}
}
/**
* Gets user configuration data and where data should be stored
*/

View File

@@ -1,124 +0,0 @@
/**
* showDowngradeToPersonalDialog must refresh members before the no-members
* fast path and stay non-dismissable (ESC derives from `closable` in
* dialogStore); fast-path failures must toast.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const toastAdd = vi.hoisted(() => vi.fn())
const refreshMembers = vi.hoisted(() => vi.fn())
const downgradeToPersonal = vi.hoisted(() => vi.fn())
const hasOtherMembers = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog })
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/platform/workspace/composables/useDowngradeToPersonal', () => ({
useDowngradeToPersonal: () => ({
hasOtherMembers,
refreshMembers,
downgradeToPersonal
})
}))
vi.mock(
'@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue',
() => ({ default: { name: 'DowngradeRemoveMembersDialogContent' } })
)
import { useDialogService } from '@/services/dialogService'
describe('showDowngradeToPersonalDialog', () => {
beforeEach(() => {
vi.resetAllMocks()
hasOtherMembers.value = false
refreshMembers.mockResolvedValue(undefined)
downgradeToPersonal.mockResolvedValue(undefined)
})
const options = { planName: 'Standard', planSlug: 'standard-monthly' }
it('refreshes members before deciding the no-members fast path', async () => {
const calls: string[] = []
refreshMembers.mockImplementation(() => {
calls.push('refresh')
return Promise.resolve()
})
downgradeToPersonal.mockImplementation(() => {
calls.push('downgrade')
return Promise.resolve()
})
await useDialogService().showDowngradeToPersonalDialog(options)
expect(calls).toEqual(['refresh', 'downgrade'])
expect(downgradeToPersonal).toHaveBeenCalledWith('standard-monthly')
expect(showDialog).not.toHaveBeenCalled()
})
it('shows a non-dismissable confirm dialog when other members exist', async () => {
hasOtherMembers.value = true
await useDialogService().showDowngradeToPersonalDialog(options)
expect(downgradeToPersonal).not.toHaveBeenCalled()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('downgrade-remove-members')
expect(args.props.onConfirm).toBe(downgradeToPersonal)
expect(args.dialogComponentProps.closable).toBe(false)
expect(args.dialogComponentProps.dismissableMask).toBe(false)
})
it('toasts and does not rethrow when the fast-path downgrade fails', async () => {
downgradeToPersonal.mockRejectedValue(new Error('Outstanding balance'))
await useDialogService().showDowngradeToPersonalDialog(options)
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'Outstanding balance'
})
)
expect(showDialog).not.toHaveBeenCalled()
})
it('toasts and aborts when the member refresh fails', async () => {
hasOtherMembers.value = true
refreshMembers.mockRejectedValue(new Error('network'))
await useDialogService().showDowngradeToPersonalDialog(options)
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error', detail: 'network' })
)
expect(showDialog).not.toHaveBeenCalled()
expect(downgradeToPersonal).not.toHaveBeenCalled()
})
})

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

@@ -10,7 +10,6 @@ import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import type {
DialogComponentProps,
@@ -34,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'
@@ -214,8 +199,6 @@ export const useDialogService = () => {
},
headerComponent: ComfyOrgHeader,
dialogComponentProps: {
renderer: 'reka',
contentClass: HUG_CONTENT_CLASS,
closable: false,
onClose: () => resolve(false)
}
@@ -239,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)
}
@@ -348,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' }
}
}
})
}
@@ -369,10 +351,6 @@ export const useDialogService = () => {
props: {
onSuccess: () =>
dialogStore.closeDialog({ key: 'global-update-password' })
},
dialogComponentProps: {
renderer: 'reka',
contentClass: HUG_CONTENT_CLASS
}
})
}
@@ -402,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({
@@ -427,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
}
})
@@ -455,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?: {
@@ -471,7 +465,7 @@ export const useDialogService = () => {
key: 'delete-workspace',
component,
props: options,
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -485,7 +479,7 @@ export const useDialogService = () => {
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -504,7 +498,7 @@ export const useDialogService = () => {
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -515,7 +509,7 @@ export const useDialogService = () => {
return dialogStore.showDialog({
key: 'leave-workspace',
component,
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -526,7 +520,7 @@ export const useDialogService = () => {
key: 'edit-workspace',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -538,7 +532,7 @@ export const useDialogService = () => {
key: 'remove-member',
component,
props: { memberId },
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -549,7 +543,7 @@ export const useDialogService = () => {
key: 'invite-member',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -561,7 +555,7 @@ export const useDialogService = () => {
key: 'invite-member-upsell',
component,
dialogComponentProps: {
...workspaceDialogProps
...workspaceDialogPt
}
})
}
@@ -573,7 +567,7 @@ export const useDialogService = () => {
key: 'revoke-invite',
component,
props: { inviteId },
dialogComponentProps: workspaceDialogProps
dialogComponentProps: workspaceDialogPt
})
}
@@ -603,54 +597,7 @@ export const useDialogService = () => {
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogProps
}
})
}
/**
* Downgrade a team plan to a personal plan (FE-977). Skips the type-"I
* understand" confirm dialog when the workspace has no other members;
* failures on that path surface as an error toast.
*/
async function showDowngradeToPersonalDialog(options: {
planName: string
planSlug: string
}) {
const { useDowngradeToPersonal } =
await import('@/platform/workspace/composables/useDowngradeToPersonal')
const { hasOtherMembers, refreshMembers, downgradeToPersonal } =
useDowngradeToPersonal()
try {
await refreshMembers()
if (!hasOtherMembers.value) {
await downgradeToPersonal(options.planSlug)
return
}
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('subscription.downgrade.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
return
}
const { default: component } =
await import('@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue')
return dialogStore.showDialog({
key: 'downgrade-remove-members',
component,
props: {
planName: options.planName,
planSlug: options.planSlug,
onConfirm: downgradeToPersonal
},
dialogComponentProps: {
...workspaceDialogProps,
closable: false,
dismissableMask: false
...workspaceDialogPt
}
})
}
@@ -665,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()
}
})
@@ -680,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' }
}
}
})
}
@@ -716,7 +663,6 @@ export const useDialogService = () => {
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog,
showDowngradeToPersonalDialog
showCancelSubscriptionDialog
}
}

View File

@@ -5,10 +5,7 @@ import { nextTick, watch } from 'vue'
import { useAssetsStore } from '@/stores/assetsStore'
import { api } from '@/scripts/api'
import type {
AssetItem,
AssetResponse
} from '@/platform/assets/schemas/assetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
@@ -28,7 +25,6 @@ vi.mock('@/scripts/api', () => ({
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetsByTag: vi.fn(),
getAssetsPageByTag: vi.fn(),
getAllAssetsByTag: vi.fn(),
getAssetsForNodeType: vi.fn(),
invalidateInputAssetsIncludingPublic: vi.fn(),
@@ -1521,50 +1517,44 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
tags: ['output']
})
const makePage = (
assets: AssetItem[],
{
hasMore = false,
nextCursor
}: { hasMore?: boolean; nextCursor?: string } = {}
): AssetResponse => ({
assets,
total: assets.length,
has_more: hasMore,
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
vi.clearAllMocks()
})
it('fetches the first page via getAssetsPageByTag with the output tag and page size', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([
makeAsset('a1', 'image1.png', 'hash1.png'),
makeAsset('a2', 'image2.png', 'hash2.png')
])
)
it('fetches outputs via getAssetsByTag with the output tag and page size', async () => {
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
makeAsset('a1', 'image1.png', 'hash1.png'),
makeAsset('a2', 'image2.png', 'hash2.png')
])
const store = useAssetsStore()
await store.updateFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenCalledWith(
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'output',
true,
{
limit: FLAT_OUTPUT_PAGE_SIZE,
offset: 0
}
expect.objectContaining({ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 })
)
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1', 'a2'])
})
it('trusts server has_more over page size for a short page', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'one.png')], { hasMore: true })
it('marks hasMore=false when the page is short', async () => {
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
makeAsset('a1', 'one.png')
])
const store = useAssetsStore()
await store.updateFlatOutputs()
expect(store.flatOutputHasMore).toBe(false)
})
it('marks hasMore=true when a full page is returned', async () => {
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
makeAsset(`a${i}`, `f${i}.png`)
)
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(fullPage)
const store = useAssetsStore()
await store.updateFlatOutputs()
@@ -1572,94 +1562,6 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputHasMore).toBe(true)
})
it('marks hasMore=false when the server reports the last page', async () => {
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
makeAsset(`a${i}`, `f${i}.png`)
)
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage(fullPage, { hasMore: false })
)
const store = useAssetsStore()
await store.updateFlatOutputs()
expect(store.flatOutputHasMore).toBe(false)
})
it('threads the minted cursor into after on loadMore and omits offset', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], {
hasMore: true,
nextCursor: 'cursor-1'
})
)
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
'output',
true,
{ limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' }
)
})
it('falls back to offset paging when the server mints no cursor', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png'), makeAsset('a2', 'f2.png')], {
hasMore: true
})
)
.mockResolvedValueOnce(makePage([makeAsset('a3', 'f3.png')]))
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
'output',
true,
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 2 }
)
})
it('stops when the server returns a non-advancing cursor', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], {
hasMore: true,
nextCursor: 'stuck'
})
)
.mockResolvedValueOnce(
makePage([makeAsset('a2', 'f2.png')], {
hasMore: true,
nextCursor: 'stuck'
})
)
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(store.flatOutputHasMore).toBe(false)
})
it('treats an empty page as terminal even when has_more is true', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([], { hasMore: true })
)
const store = useAssetsStore()
await store.updateFlatOutputs()
expect(store.flatOutputHasMore).toBe(false)
})
it('appends and dedupes on loadMoreFlatOutputs', async () => {
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
makeAsset(`a${i}`, `f${i}.png`)
@@ -1668,9 +1570,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
makeAsset('a0', 'duplicate.png'),
makeAsset('newId', 'new.png')
]
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(makePage(firstPage, { hasMore: true }))
.mockResolvedValueOnce(makePage(secondPage))
vi.mocked(assetService.getAssetsByTag)
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce(secondPage)
const store = useAssetsStore()
await store.updateFlatOutputs()
@@ -1680,9 +1582,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputAssets.at(-1)?.id).toBe('newId')
})
it('records error and resolves to an empty list on initial-fetch failure', async () => {
it('records error and clears media on initial-fetch failure', async () => {
const err = new Error('network down')
vi.mocked(assetService.getAssetsPageByTag).mockRejectedValueOnce(err)
vi.mocked(assetService.getAssetsByTag).mockRejectedValueOnce(err)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
@@ -1697,105 +1599,37 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
}
})
it('preserves the cursor for retry when loadMore fails', async () => {
const err = new Error('network down')
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], {
hasMore: true,
nextCursor: 'cursor-1'
})
)
.mockRejectedValueOnce(err)
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(store.flatOutputError).toBe(err)
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1'])
expect(store.flatOutputHasMore).toBe(true)
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
'output',
true,
{ limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' }
)
} finally {
consoleSpy.mockRestore()
}
})
it('restarts from the head when loadMore follows a failed refresh', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], {
hasMore: true,
nextCursor: 'cursor-1'
})
)
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
'output',
true,
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 }
)
} finally {
consoleSpy.mockRestore()
}
})
it('refresh resets pagination', async () => {
vi.mocked(assetService.getAssetsPageByTag)
vi.mocked(assetService.getAssetsByTag)
.mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], {
hasMore: true,
nextCursor: 'cursor-1'
})
Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
makeAsset(`a${i}`, `f${i}.png`)
)
)
.mockResolvedValueOnce(makePage([makeAsset('fresh', 'fresh.png')]))
.mockResolvedValueOnce([makeAsset('fresh', 'fresh.png')])
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.updateFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
'output',
true,
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 }
)
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh'])
expect(store.flatOutputHasMore).toBe(false)
})
it('dedupes concurrent fetches into a single request', async () => {
let resolvePage!: (page: AssetResponse) => void
const pagePromise = new Promise<AssetResponse>((res) => {
let resolvePage!: (assets: AssetItem[]) => void
const pagePromise = new Promise<AssetItem[]>((res) => {
resolvePage = res
})
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(pagePromise)
vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise)
const store = useAssetsStore()
const p1 = store.updateFlatOutputs()
const p2 = store.updateFlatOutputs()
expect(vi.mocked(assetService.getAssetsPageByTag)).toHaveBeenCalledTimes(1)
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1)
resolvePage(makePage([makeAsset('shared-1', 'shared.png', 'h.png')]))
resolvePage([makeAsset('shared-1', 'shared.png', 'h.png')])
await Promise.all([p1, p2])
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])

View File

@@ -267,7 +267,6 @@ export const useAssetsStore = defineStore('assets', () => {
const flatOutputHasMore = ref(true)
const flatOutputIsLoadingMore = ref(false)
const flatOutputSeenIds = new Set<string>()
let flatOutputNextCursor: string | undefined
let flatOutputInFlight: Promise<AssetItem[]> | null = null
async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
@@ -279,36 +278,26 @@ export const useAssetsStore = defineStore('assets', () => {
} else {
flatOutputLoading.value = true
flatOutputOffset.value = 0
flatOutputNextCursor = undefined
flatOutputHasMore.value = true
flatOutputSeenIds.clear()
}
flatOutputError.value = null
flatOutputInFlight = (async () => {
const requestedAfter = loadMore ? flatOutputNextCursor : undefined
try {
const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, {
const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, {
limit: FLAT_OUTPUT_PAGE_SIZE,
...(requestedAfter
? { after: requestedAfter }
: { offset: flatOutputOffset.value })
offset: flatOutputOffset.value
})
const batch = page.assets
const fresh = loadMore
? batch.filter((asset) => !flatOutputSeenIds.has(asset.id))
: batch
? page.filter((asset) => !flatOutputSeenIds.has(asset.id))
: page
for (const asset of fresh) flatOutputSeenIds.add(asset.id)
flatOutputAssets.value = loadMore
? [...flatOutputAssets.value, ...fresh]
: batch
flatOutputOffset.value += batch.length
const nextCursor = page.next_cursor || undefined
const cursorStuck =
nextCursor !== undefined && nextCursor === requestedAfter
flatOutputNextCursor = cursorStuck ? undefined : nextCursor
flatOutputHasMore.value =
fresh.length > 0 && page.has_more && !cursorStuck
: page
flatOutputOffset.value += page.length
flatOutputHasMore.value = page.length === FLAT_OUTPUT_PAGE_SIZE
return flatOutputAssets.value
} catch (err) {
flatOutputError.value = err

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: () => {

View File

@@ -1,6 +1,5 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
@@ -12,30 +11,24 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
// Create mock functions that will be shared
const {
mockNodeExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeLocatorIdToNodeExecutionId,
mockActiveWorkflow,
mockOpenWorkflows,
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionSuccess,
mockTrackSharedWorkflowRun
} = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
return {
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockActiveWorkflow: shallowRef<{ path?: string } | null>(null),
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}
})
} = vi.hoisted(() => ({
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}))
const mockAppModeState = vi.hoisted(() => ({
mode: { value: 'graph' },
@@ -54,6 +47,7 @@ beforeEach(() => {
mockAppModeState.mode.value = 'graph'
mockAppModeState.isAppMode.value = false
})
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -67,15 +61,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
useWorkflowStore: vi.fn(() => ({
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
get activeWorkflow() {
return mockActiveWorkflow.value
},
get openWorkflows() {
return mockOpenWorkflows.value
},
isOpen: (workflow: { path?: string }) =>
mockOpenWorkflows.value.some((w) => w.path === workflow.path)
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
}))
}
})
@@ -149,11 +135,6 @@ vi.mock('@/scripts/app', () => ({
}
}))
beforeEach(() => {
mockActiveWorkflow.value = null
mockOpenWorkflows.value = []
})
function createQueuedWorkflow(path: string = 'workflows/test.json') {
return {
activeState: { id: 'workflow-id' },
@@ -520,254 +501,6 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
describe('useExecutionStore - workflowStatus', () => {
let store: ReturnType<typeof useExecutionStore>
type Workflow = Parameters<typeof store.storeJob>[0]['workflow']
const makeWorkflow = (path: string): Workflow => {
const workflow: Partial<Workflow> = {
path,
filename: path.split('/').pop()
}
return workflow as Workflow
}
const workflowA = makeWorkflow('/workflows/a.json')
const workflowB = makeWorkflow('/workflows/b.json')
function fireExecutionStart(jobId: string) {
const handler = apiEventHandlers.get('execution_start')
if (!handler) throw new Error('execution_start handler not bound')
handler(
new CustomEvent('execution_start', { detail: { prompt_id: jobId } })
)
}
function fireExecutionSuccess(jobId: string) {
const handler = apiEventHandlers.get('execution_success')
if (!handler) throw new Error('execution_success handler not bound')
handler(
new CustomEvent('execution_success', { detail: { prompt_id: jobId } })
)
}
function fireExecutionError(jobId: string) {
const handler = apiEventHandlers.get('execution_error')
if (!handler) throw new Error('execution_error handler not bound')
handler(
new CustomEvent('execution_error', {
detail: {
prompt_id: jobId,
node_id: '1',
node_type: 'TestNode',
exception_message: 'fail',
exception_type: 'Error',
traceback: []
}
})
)
}
function fireExecutionInterrupted(jobId: string) {
const handler = apiEventHandlers.get('execution_interrupted')
if (!handler) throw new Error('execution_interrupted handler not bound')
handler(
new CustomEvent('execution_interrupted', {
detail: { prompt_id: jobId }
})
)
}
function callStoreJob(jobId: string, workflow: Workflow) {
store.storeJob({
nodes: ['1'],
id: jobId,
promptOutput: { '1': createPromptNode('Node', 'TestNode') },
workflow
})
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
mockOpenWorkflows.value = [workflowA, workflowB]
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
it('sets running on execution_start when storeJob already ran', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('flushes running status when storeJob arrives after WS', () => {
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('flushes terminal completed when WS finishes before storeJob', () => {
// Instant-finish race: WS fires start+success before HTTP response.
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('flushes terminal failed when WS errors before storeJob', () => {
// Invalid-workflow path: execution_error fires before HTTP response.
fireExecutionError('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
})
it('drops pending status on interrupt before storeJob', () => {
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('sets completed on execution_success', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('sets failed on execution_error', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionError('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
})
it('skips status badge on user-initiated interrupt', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionInterrupted('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('evicts the oldest pending status once the buffer cap is exceeded', () => {
// Each start with no matching storeJob buffers a 'running' status. One
// past the cap evicts the oldest so the buffer can't grow unbounded.
for (let i = 0; i <= MAX_PROGRESS_JOBS; i++) fireExecutionStart(`job-${i}`)
callStoreJob('job-0', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
callStoreJob(`job-${MAX_PROGRESS_JOBS}`, workflowB)
expect(store.getWorkflowStatus(workflowB)).toBe('running')
})
it('overwrites stale terminal with running on re-queue', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
// Re-queue the same workflow under a fresh jobId.
callStoreJob('job-2', workflowA)
fireExecutionStart('job-2')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('ignores status events for unknown prompt ids', () => {
fireExecutionSuccess('unknown-job')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
expect(store.getWorkflowStatus(workflowB)).toBeUndefined()
})
it('prunes only closed workflows, leaving open ones intact', async () => {
callStoreJob('job-a', workflowA)
callStoreJob('job-b', workflowB)
fireExecutionSuccess('job-a')
fireExecutionSuccess('job-b')
mockOpenWorkflows.value = [workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
expect(store.getWorkflowStatus(workflowB)).toBe('completed')
})
it('ignores terminal events for a workflow closed mid-run', async () => {
callStoreJob('job-a', workflowA)
fireExecutionStart('job-a')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
// Close the tab while the job is still running.
mockOpenWorkflows.value = [workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
// A late success must not resurrect an entry for the closed workflow.
fireExecutionSuccess('job-a')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('drops service-level errors without writing failed', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('running')
// Service-level error: empty node_id triggers the short-circuit branch.
const handler = apiEventHandlers.get('execution_error')
handler!(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '',
node_type: '',
exception_message: 'Job has stagnated',
exception_type: 'StagnationError',
traceback: []
}
})
)
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
it('drops pending failed when service-level error fires before storeJob', () => {
apiEventHandlers.get('execution_error')!(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '',
node_type: '',
exception_message: 'Job has stagnated',
exception_type: 'StagnationError',
traceback: []
}
})
)
callStoreJob('job-1', workflowA)
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('clears workflowStatus on unbindExecutionEvents', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
store.unbindExecutionEvents()
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
})
describe('useExecutionStore - clearActiveJobIfStale', () => {
let store: ReturnType<typeof useExecutionStore>

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import { useAppMode } from '@/composables/useAppMode'
@@ -93,17 +93,6 @@ function buildExecutionNodeLookup(
*/
export const MAX_PROGRESS_JOBS = 1000
export type WorkflowExecutionStatus = 'running' | 'completed' | 'failed'
export const WORKFLOW_STATUS_I18N_KEYS: Record<
WorkflowExecutionStatus,
string
> = {
running: 'g.running',
completed: 'g.completed',
failed: 'g.failed'
}
export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -132,86 +121,6 @@ export const useExecutionStore = defineStore('execution', () => {
const initializingJobIds = ref<Set<JobId>>(new Set())
const workflowStatus = shallowRef<
Map<ComfyWorkflow, WorkflowExecutionStatus>
>(new Map())
const jobIdToWorkflow = new Map<string, ComfyWorkflow>()
// Buffers statuses arriving before storeJob attaches the workflow.
// FIFO-capped to bound growth if a matching storeJob never fires.
const pendingWorkflowStatusByJobId = new Map<
string,
WorkflowExecutionStatus
>()
function bufferPendingWorkflowStatus(
jobId: string,
status: WorkflowExecutionStatus
) {
pendingWorkflowStatusByJobId.delete(jobId)
pendingWorkflowStatusByJobId.set(jobId, status)
while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) {
const oldest = pendingWorkflowStatusByJobId.keys().next().value
if (oldest === undefined) break
pendingWorkflowStatusByJobId.delete(oldest)
}
}
function mutateStatus(
mutator: (map: Map<ComfyWorkflow, WorkflowExecutionStatus>) => void
) {
const next = new Map(workflowStatus.value)
mutator(next)
workflowStatus.value = next
}
function applyWorkflowStatus(
workflow: ComfyWorkflow,
status: WorkflowExecutionStatus
) {
// A late terminal event can arrive after the tab closed; don't resurrect
// an entry (which also pins the workflow ref) for a closed workflow.
if (!workflowStore.isOpen(workflow)) return
mutateStatus((m) => m.set(workflow, status))
}
function setWorkflowStatus(jobId: string, status: WorkflowExecutionStatus) {
const workflow = jobIdToWorkflow.get(jobId)
if (!workflow) {
bufferPendingWorkflowStatus(jobId, status)
return
}
applyWorkflowStatus(workflow, status)
}
function clearWorkflowStatus(workflow: ComfyWorkflow) {
if (!workflowStatus.value.has(workflow)) return
mutateStatus((m) => m.delete(workflow))
}
function getWorkflowStatus(
workflow: ComfyWorkflow | undefined | null
): WorkflowExecutionStatus | undefined {
if (!workflow) return undefined
return workflowStatus.value.get(workflow)
}
// Prune statuses for workflows that have been closed.
watch(
() => workflowStore.openWorkflows,
(openWorkflows) => {
if (workflowStatus.value.size === 0) return
const openSet = new Set(openWorkflows)
const filtered = new Map(
[...workflowStatus.value].filter(([w]) => openSet.has(w))
)
if (filtered.size !== workflowStatus.value.size) {
workflowStatus.value = filtered
}
}
)
/**
* Cache for executionIdToNodeLocatorId lookups.
* Avoids redundant graph traversals during a single execution run.
@@ -364,10 +273,6 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
if (workflowStatus.value.size > 0) workflowStatus.value = new Map()
pendingWorkflowStatusByJobId.clear()
jobIdToWorkflow.clear()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -383,7 +288,6 @@ export const useExecutionStore = defineStore('execution', () => {
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
}
setWorkflowStatus(activeJobId.value, 'running')
}
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
@@ -397,10 +301,6 @@ export const useExecutionStore = defineStore('execution', () => {
e: CustomEvent<ExecutionInterruptedWsMessage>
) {
const jobId = e.detail.prompt_id
// User-initiated stop is not a failure — drop the badge entirely.
pendingWorkflowStatusByJobId.delete(jobId)
const workflow = jobIdToWorkflow.get(jobId)
if (workflow) clearWorkflowStatus(workflow)
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
resetExecutionState(jobId)
}
@@ -412,7 +312,6 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
const jobId = e.detail.prompt_id
setWorkflowStatus(jobId, 'completed')
const queuedJob = queuedJobs.value[jobId]
const telemetry = useTelemetry()
if (queuedJob) {
@@ -534,11 +433,7 @@ export const useExecutionStore = defineStore('execution', () => {
if (isCloud) {
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
// Pre-flight validation isn't a runtime failure — no badge.
if (handleCloudValidationError(e.detail)) {
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
return
}
if (handleCloudValidationError(e.detail)) return
}
// Account preconditions (sign-in, subscription, credits) open their own
@@ -546,12 +441,10 @@ export const useExecutionStore = defineStore('execution', () => {
if (handleAccountPreconditionError(e.detail)) return
// Service-level errors (e.g. "Job has stagnated") have no associated node.
if (handleServiceLevelError(e.detail)) {
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
return
}
// Route them as job errors
if (handleServiceLevelError(e.detail)) return
setWorkflowStatus(e.detail.prompt_id, 'failed')
// OSS path / Cloud fallback (real runtime errors)
executionErrorStore.lastExecutionError = e.detail
clearInitializationByJobId(e.detail.prompt_id)
resetExecutionState(e.detail.prompt_id)
@@ -676,7 +569,6 @@ export const useExecutionStore = defineStore('execution', () => {
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
jobIdToWorkflow.delete(jobId)
}
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
@@ -732,7 +624,6 @@ export const useExecutionStore = defineStore('execution', () => {
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
if (workflow) jobIdToWorkflow.set(String(id), workflow)
queuedJob.shareId = workflow?.shareId
const queuedMode = getWorkflowMode(workflow)
queuedJob.viewMode = queuedMode
@@ -744,19 +635,6 @@ export const useExecutionStore = defineStore('execution', () => {
if (workflow?.path) {
ensureSessionWorkflowPath(id, workflow.path)
}
flushPendingWorkflowStatus(String(id), workflow)
}
function flushPendingWorkflowStatus(
jobId: string,
workflow: ComfyWorkflow | undefined
) {
const pending = pendingWorkflowStatusByJobId.get(jobId)
if (pending === undefined || !workflow) return
pendingWorkflowStatusByJobId.delete(jobId)
// Don't let a stale 'running' overwrite a terminal status already set.
if (pending === 'running' && workflowStatus.value.has(workflow)) return
applyWorkflowStatus(workflow, pending)
}
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
@@ -851,8 +729,6 @@ export const useExecutionStore = defineStore('execution', () => {
nodeLocatorIdToExecutionId,
jobIdToWorkflowId,
jobIdToSessionWorkflowPath,
ensureSessionWorkflowPath,
getWorkflowStatus,
clearWorkflowStatus
ensureSessionWorkflowPath
}
})

View File

@@ -88,9 +88,10 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
const bottomLeftRef = useTemplateRef<HTMLDivElement>('bottomLeftRef')
const bottomRightRef = useTemplateRef<HTMLDivElement>('bottomRightRef')
const linearWorkflowRef =
useTemplateRef<InstanceType<typeof LinearControls>>('linearWorkflowRef')
function dragDrop(e: DragEvent) {
const { dataTransfer } = e