Compare commits

..

2 Commits

Author SHA1 Message Date
Jin Yi
5d00db2143 test: add missing mocks to useJobMenu.test.ts
- Add isCloud mock for Cloud environment handling
- Add ComfyWorkflow export to workflowStore mock
- Add executionStore mock for clearInitializationByPromptId

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:41:33 +09:00
Jin Yi
6e9c6c6f2e fix: when clear queue button clicked, initialized tasks are not cleared 2026-01-21 13:13:47 +09:00
213 changed files with 6710 additions and 12473 deletions

View File

@@ -119,7 +119,8 @@ class NodeSlotReference {
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float64Arrays to regular arrays for visibility
console.warn(
// eslint-disable-next-line no-console
console.log(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{
nodePos: [node.pos[0], node.pos[1]],

View File

@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'feature_flags') {
window.__capturedMessages!.clientFeatureFlags = parsed
window.__capturedMessages.clientFeatureFlags = parsed
}
} catch (e) {
// Not JSON, ignore
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
) {
window.__capturedMessages!.serverFeatureFlags =
window.__capturedMessages.serverFeatureFlags =
window['app'].api.serverFeatureFlags
clearInterval(checkInterval)
}
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
// Wait for both client and server feature flags
await newPage.waitForFunction(
() =>
window.__capturedMessages!.clientFeatureFlags !== null &&
window.__capturedMessages!.serverFeatureFlags !== null,
window.__capturedMessages.clientFeatureFlags !== null &&
window.__capturedMessages.serverFeatureFlags !== null,
{ timeout: 10000 }
)
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
const messages = await newPage.evaluate(() => window.__capturedMessages)
// Verify client sent feature flags
expect(messages!.clientFeatureFlags).toBeTruthy()
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages!.clientFeatureFlags).toHaveProperty('data')
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
expect(messages.clientFeatureFlags).toBeTruthy()
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages.clientFeatureFlags).toHaveProperty('data')
expect(messages.clientFeatureFlags.data).toHaveProperty(
'supports_preview_metadata'
)
expect(
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
typeof messages.clientFeatureFlags.data.supports_preview_metadata
).toBe('boolean')
// Verify server sent feature flags back
expect(messages!.serverFeatureFlags).toBeTruthy()
expect(messages!.serverFeatureFlags).toHaveProperty(
expect(messages.serverFeatureFlags).toBeTruthy()
expect(messages.serverFeatureFlags).toHaveProperty(
'supports_preview_metadata'
)
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
await newPage.close()
})
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverFeatureFlags
return window['app'].api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
}) => {
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverSupportsFeature(
return window['app'].api.serverSupportsFeature(
'supports_preview_metadata'
)
})
@@ -124,17 +124,15 @@ test.describe('Feature Flags', () => {
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window['app']!.api.serverSupportsFeature(
'non_existent_feature_xyz'
)
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
})
expect(supportsNonExistent).toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window['app']!.api.serverFeatureFlags
window['app']!.api.serverFeatureFlags = {
const original = window['app'].api.serverFeatureFlags
window['app'].api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -143,15 +141,15 @@ test.describe('Feature Flags', () => {
}
const results = {
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
string_value: window['app']!.api.serverSupportsFeature('string_value'),
number_value: window['app']!.api.serverSupportsFeature('number_value'),
null_value: window['app']!.api.serverSupportsFeature('null_value')
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
string_value: window['app'].api.serverSupportsFeature('string_value'),
number_value: window['app'].api.serverSupportsFeature('number_value'),
null_value: window['app'].api.serverSupportsFeature('null_value')
}
// Restore original
window['app']!.api.serverFeatureFlags = original
window['app'].api.serverFeatureFlags = original
return results
})
@@ -168,20 +166,20 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature('supports_preview_metadata')
return window['app'].api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature('max_upload_size')
return window['app'].api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeature(
return window['app'].api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
@@ -194,7 +192,7 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window['app']!.api.getServerFeatures()
return window['app'].api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
@@ -207,14 +205,14 @@ test.describe('Feature Flags', () => {
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
const flags1 = window['app']!.api.getClientFeatureFlags()
const flags2 = window['app']!.api.getClientFeatureFlags()
const flags1 = window['app'].api.getClientFeatureFlags()
const flags2 = window['app'].api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
const flags3 = window['app']!.api.getClientFeatureFlags()
const flags3 = window['app'].api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
@@ -240,14 +238,14 @@ test.describe('Feature Flags', () => {
}) => {
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
const features1 = window['app']!.api.getServerFeatures()
const features1 = window['app'].api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
const features2 = window['app']!.api.getServerFeatures()
const features2 = window['app'].api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
@@ -276,8 +274,7 @@ test.describe('Feature Flags', () => {
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// Track when various app components are ready
window.__appReadiness = {
;(window as any).__appReadiness = {
featureFlagsReceived: false,
apiInitialized: false,
appInitialized: false
@@ -289,10 +286,7 @@ test.describe('Feature Flags', () => {
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
window.__appReadiness = {
...window.__appReadiness,
featureFlagsReceived: true
}
;(window as any).__appReadiness.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
}
}, 10)
@@ -300,10 +294,7 @@ test.describe('Feature Flags', () => {
// Monitor API initialization
const checkApi = setInterval(() => {
if (window['app']?.api) {
window.__appReadiness = {
...window.__appReadiness,
apiInitialized: true
}
;(window as any).__appReadiness.apiInitialized = true
clearInterval(checkApi)
}
}, 10)
@@ -311,10 +302,7 @@ test.describe('Feature Flags', () => {
// Monitor app initialization
const checkApp = setInterval(() => {
if (window['app']?.graph) {
window.__appReadiness = {
...window.__appReadiness,
appInitialized: true
}
;(window as any).__appReadiness.appInitialized = true
clearInterval(checkApp)
}
}, 10)
@@ -343,8 +331,8 @@ test.describe('Feature Flags', () => {
// Get readiness state
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window['app']!.api.serverFeatureFlags
...(window as any).__appReadiness,
currentFlags: window['app'].api.serverFeatureFlags
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -2,17 +2,15 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const app = window['app']!
const app = window['app']
const canvas = app.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
@@ -347,7 +345,7 @@ This is documentation for a custom node.
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
return window['app']!.graph!.nodes.map((n) => n.id)
return window['app'].graph.nodes.map((n: any) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])

View File

@@ -1,7 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -16,7 +15,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const openMoreOptions = async (comfyPage: any) => {
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')

View File

@@ -189,7 +189,9 @@ test.describe('Templates', () => {
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
const nav = comfyPage.page
.locator('header')
.filter({ hasText: 'Templates' })
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
@@ -199,8 +201,7 @@ test.describe('Templates', () => {
await comfyPage.page.setViewportSize(mobileSize)
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
// Nav header is clipped by overflow-hidden parent at mobile size
await expect(nav).not.toBeInViewport()
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -419,7 +419,7 @@ test.describe('Vue Node Link Interaction', () => {
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = window['app']
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
@@ -505,7 +505,7 @@ test.describe('Vue Node Link Interaction', () => {
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = window['app']
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -76,7 +76,6 @@ function getModuleName(id: string): string {
export function comfyAPIPlugin(isDev: boolean): Plugin {
return {
name: 'comfy-api-plugin',
apply: 'build',
transform(code: string, id: string) {
if (isDev) return null

View File

@@ -5,7 +5,7 @@
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,

View File

@@ -30,10 +30,6 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## i18n in Component Tests
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
## Mock Patterns
### Reset all mocks at once

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.10",
"version": "1.38.8",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -169,7 +169,6 @@
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "^11.0.3",
"jsonata": "catalog:",
"jsondiffpatch": "^0.6.0",
"loglevel": "^1.9.2",
"marked": "^15.0.11",

View File

@@ -0,0 +1,19 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="hollow">
<path
d="M -50 50
A 100 100, 0, 0, 1, 150 50
A 100 100, 0, 0, 1, -50 50
M 30 50
A 20 20, 0, 0, 0, 70 50
A 20 20, 0, 0, 0, 30 50"/>
</clipPath>
</defs>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M 50 0 A 50 50, 0, 0, 1, 50 100" fill="var(--type1, red)"/>
<path d="M 50 100 A 50 50, 0, 0, 1, 50 0" fill="var(--type2, blue)"/>
<path d="M50 0L50 100" stroke="var(--inner-stroke, black)"/>
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 693 B

View File

@@ -0,0 +1,20 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="hollow">
<path
d="M-50 50
A100 100 0 0 1 150 50
A100 100 0 0 1 -50 50
M30 50
A20 20 0 0 0 70 50
A20 20 0 0 0 30 50"/>
</clipPath>
</defs>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M50 0A50 50 0 0 1 93 75L50 50" fill="var(--type1, red)"/>
<path d="M93 75A50 50 0 0 1 7 75L50 50" fill="var(--type2, blue)"/>
<path d="M7 75A50 50 0 0 1 50 0L50 50" fill="var(--type3, green)"/>
<path d="M50 50L50 0M50 50L93 75M50 50L7 75" stroke="var(--inner-stroke, black)"/>
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 763 B

View File

@@ -120,8 +120,8 @@ describe('formatUtil', () => {
})
it('should handle null and undefined gracefully', () => {
expect(getMediaTypeFromFilename(null)).toBe('image')
expect(getMediaTypeFromFilename(undefined)).toBe('image')
expect(getMediaTypeFromFilename(null as any)).toBe('image')
expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
})
it('should handle special characters in filenames', () => {

View File

@@ -537,9 +537,7 @@ export function truncateFilename(
* @param filename The filename to analyze
* @returns The media type: 'image', 'video', 'audio', or '3D'
*/
export function getMediaTypeFromFilename(
filename: string | null | undefined
): MediaType {
export function getMediaTypeFromFilename(filename: string): MediaType {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'

12
pnpm-lock.yaml generated
View File

@@ -186,9 +186,6 @@ catalogs:
jsdom:
specifier: ^27.4.0
version: 27.4.0
jsonata:
specifier: ^2.1.0
version: 2.1.0
knip:
specifier: ^5.75.1
version: 5.75.1
@@ -452,9 +449,6 @@ importers:
glob:
specifier: ^11.0.3
version: 11.0.3
jsonata:
specifier: 'catalog:'
version: 2.1.0
jsondiffpatch:
specifier: ^0.6.0
version: 0.6.0
@@ -6051,10 +6045,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonata@2.1.0:
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
engines: {node: '>= 8'}
jsonc-eslint-parser@2.4.0:
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -14413,8 +14403,6 @@ snapshots:
json5@2.2.3: {}
jsonata@2.1.0: {}
jsonc-eslint-parser@2.4.0:
dependencies:
acorn: 8.15.0

View File

@@ -62,7 +62,6 @@ catalog:
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsonata: ^2.1.0
jsdom: ^27.4.0
knip: ^5.75.1
lint-staged: ^16.2.7

View File

@@ -9,7 +9,6 @@
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import { useEventListener } from '@vueuse/core'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
@@ -17,6 +16,10 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { t } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -24,6 +27,8 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -49,15 +54,23 @@ onMounted(() => {
document.addEventListener('contextmenu', showContextMenu)
}
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }
})
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
window.addEventListener('vite:preloadError', async (_event) => {
// Auto-reload if app is not ready or there are no unsaved changes
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
window.location.reload()
} else {
console.error('[vite:preloadError]', event.payload)
// Show confirmation dialog if there are unsaved changes
await dialogService
.confirm({
title: t('g.vitePreloadErrorTitle'),
message: t('g.vitePreloadErrorMessage')
})
.then((confirmed) => {
if (confirmed) {
window.location.reload()
}
})
}
})

View File

@@ -14,7 +14,6 @@ interface IdleDeadline {
interface IDisposable {
dispose(): void
}
type GlobalWindow = typeof globalThis
/**
* Internal implementation function that handles the actual scheduling logic.
@@ -22,7 +21,7 @@ type GlobalWindow = typeof globalThis
* or fall back to setTimeout-based implementation.
*/
let _runWhenIdle: (
targetWindow: GlobalWindow,
targetWindow: any,
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
@@ -38,7 +37,7 @@ export let runWhenGlobalIdle: (
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
const safeGlobal: any = globalThis
if (
typeof safeGlobal.requestIdleCallback !== 'function' ||

View File

@@ -1,18 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -42,9 +36,7 @@ function createWrapper() {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
expandCollapsedQueue: 'Expand collapsed queue'
}
}
}
@@ -58,12 +50,7 @@ function createWrapper() {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
LoginButton: true
},
directives: {
tooltip: () => {}
@@ -72,19 +59,6 @@ function createWrapper() {
})
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
status,
create_time: 0,
priority: 0
}
}
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
@@ -126,39 +100,4 @@ describe('TopMenuSection', () => {
})
})
})
it('shows the active jobs label with the current count', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
]
await nextTick()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
})

View File

@@ -44,21 +44,21 @@
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
size="icon"
:aria-pressed="isQueueOverlayExpanded"
class="px-3"
data-testid="queue-overlay-toggle"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
>
{{ queuedCount }}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -86,8 +86,6 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -105,7 +103,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -120,27 +117,18 @@ const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t, n } = useI18n()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const queueUIStore = useQueueUIStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
@@ -150,18 +138,6 @@ const queueHistoryTooltipConfig = computed(() =>
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
@@ -188,19 +164,6 @@ const toggleQueueOverlay = () => {
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -152,8 +151,8 @@ describe('BaseTerminal', () => {
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
const selectionCallback = mockCalls[0][0] as () => void
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
.calls[0][0]
selectionCallback()
await nextTick()

View File

@@ -51,7 +51,7 @@ describe('EditableText', () => {
isEditing: true
})
await wrapper.findComponent(InputText).setValue('New Text')
await wrapper.findComponent(InputText).trigger('keydown.enter')
await wrapper.findComponent(InputText).trigger('keyup.enter')
// Blur event should have been triggered
expect(wrapper.findComponent(InputText).element).not.toBe(
document.activeElement
@@ -79,7 +79,7 @@ describe('EditableText', () => {
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape
await wrapper.findComponent(InputText).trigger('keydown.escape')
await wrapper.findComponent(InputText).trigger('keyup.escape')
// Should emit cancel event
expect(wrapper.emitted('cancel')).toBeTruthy()
@@ -103,7 +103,7 @@ describe('EditableText', () => {
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape (which triggers blur internally)
await wrapper.findComponent(InputText).trigger('keydown.escape')
await wrapper.findComponent(InputText).trigger('keyup.escape')
// Manually trigger blur to simulate the blur that happens after escape
await wrapper.findComponent(InputText).trigger('blur')
@@ -120,7 +120,7 @@ describe('EditableText', () => {
isEditing: true
})
await enterWrapper.findComponent(InputText).setValue('Saved Text')
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
// Trigger blur that happens after enter
await enterWrapper.findComponent(InputText).trigger('blur')
expect(enterWrapper.emitted('edit')).toBeTruthy()
@@ -133,7 +133,7 @@ describe('EditableText', () => {
isEditing: true
})
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
expect(escapeWrapper.emitted('edit')).toBeFalsy()
})

View File

@@ -3,7 +3,7 @@
<span v-if="!isEditing">
{{ modelValue }}
</span>
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
<InputText
v-else
ref="inputRef"
@@ -18,8 +18,8 @@
...inputAttrs
}
}"
@keydown.enter.capture.stop="blurInputElement"
@keydown.escape.capture.stop="cancelEditing"
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@contextmenu.stop
@pointerdown.stop.capture

View File

@@ -7,7 +7,6 @@ import { createApp } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('FormRadioGroup', () => {
beforeAll(() => {
@@ -15,8 +14,7 @@ describe('FormRadioGroup', () => {
app.use(PrimeVue)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
const mountComponent = (props: any, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
@@ -94,9 +92,9 @@ describe('FormRadioGroup', () => {
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
{ name: 'First Option', id: 1 },
{ name: 'Second Option', id: 2 },
{ name: 'Third Option', id: 3 }
]
const wrapper = mountComponent({
@@ -110,9 +108,9 @@ describe('FormRadioGroup', () => {
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
expect(radioButtons[0].props('value')).toBe(1)
expect(radioButtons[1].props('value')).toBe(2)
expect(radioButtons[2].props('value')).toBe(3)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
@@ -169,7 +167,10 @@ describe('FormRadioGroup', () => {
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const options = [
{ label: 'Option 1', val: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
@@ -178,10 +179,11 @@ describe('FormRadioGroup', () => {
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
expect(radioButtons).toHaveLength(2)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
expect(labels[1].text()).toBe('Option 2')
})
})

View File

@@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types'
const props = defineProps<{
modelValue: any
options?: (string | SettingOption | Record<string, string>)[]
options: (SettingOption | string)[]
optionLabel?: string
optionValue?: string
id?: string

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -1,27 +1,30 @@
<script setup lang="ts">
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
const {
label,
severity = 'default',
variant
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
const { label, severity = 'default' } = defineProps<{
label: string
severity?: Severity
}>()
function badgeClasses(sev: Severity): string {
const baseClasses =
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
switch (sev) {
case 'danger':
return `${baseClasses} bg-destructive-background text-white`
case 'contrast':
return `${baseClasses} bg-base-foreground text-base-background`
case 'warn':
return `${baseClasses} bg-warning-background text-base-background`
case 'secondary':
return `${baseClasses} bg-secondary-background text-base-foreground`
default:
return `${baseClasses} bg-primary-background text-base-foreground`
}
}
</script>
<template>
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
<span :class="badgeClasses(severity)">{{ label }}</span>
</template>

View File

@@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
beforeEach(() => {
@@ -15,13 +14,7 @@ describe('UrlInput', () => {
app.use(PrimeVue)
})
const mountComponent = (
props: ComponentProps<typeof UrlInput> & {
placeholder?: string
disabled?: boolean
},
options = {}
) => {
const mountComponent = (props: any, options = {}) => {
return mount(UrlInput, {
global: {
plugins: [PrimeVue],
@@ -176,25 +169,25 @@ describe('UrlInput', () => {
await input.setValue(' https://leading-space.com')
await input.trigger('input')
await nextTick()
expect(input.element.value).toBe('https://leading-space.com')
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
// Test trailing whitespace
await input.setValue('https://trailing-space.com ')
await input.trigger('input')
await nextTick()
expect(input.element.value).toBe('https://trailing-space.com')
expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
// Test both leading and trailing whitespace
await input.setValue(' https://both-spaces.com ')
await input.trigger('input')
await nextTick()
expect(input.element.value).toBe('https://both-spaces.com')
expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
// Test whitespace in the middle of the URL
await input.setValue('https:// middle-space.com')
await input.trigger('input')
await nextTick()
expect(input.element.value).toBe('https://middle-space.com')
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
@@ -203,17 +196,15 @@ describe('UrlInput', () => {
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
// Check initial value is trimmed
expect(input.element.value).toBe('https://initial-value.com')
expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
// Update props with whitespace
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
await nextTick()
// Check updated value is trimmed
expect(input.element.value).toBe('https://updated-value.com')
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
})
})
})

View File

@@ -1,5 +1,3 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import PrimeVue from 'primevue/config'
@@ -29,7 +27,7 @@ describe('UserAvatar', () => {
app.use(PrimeVue)
})
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
const mountComponent = (props: any = {}) => {
return mount(UserAvatar, {
global: {
plugins: [PrimeVue, i18n],

View File

@@ -1,20 +1,16 @@
<template>
<div
ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div
v-for="item in renderedItems"
:key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item
>
<div ref="container" class="scroll-container">
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
<div :style="gridStyle">
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
<slot name="item" :item="item" />
</div>
</div>
<div :style="bottomSpacerStyle" />
<div
:style="{
height: `${((items.length - state.end) / cols) * itemHeight}px`
}"
/>
</div>
</template>
@@ -32,22 +28,19 @@ type GridState = {
const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
maxColumns = Infinity
defaultItemWidth = 200
} = defineProps<{
items: (T & { key: string })[]
gridStyle: CSSProperties
gridStyle: Partial<CSSProperties>
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
maxColumns?: number
}>()
const emit = defineEmits<{
@@ -66,18 +59,7 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
const cols = computed(() =>
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
)
const mergedGridStyle = computed<CSSProperties>(() => {
if (maxColumns === Infinity) return gridStyle
return {
...gridStyle,
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
}
})
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
@@ -101,16 +83,6 @@ const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
function rowsToHeight(rows: number): string {
return `${(rows / cols.value) * itemHeight.value}px`
}
const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start)
}))
const bottomSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(items.length - state.value.end)
}))
whenever(
() => state.value.isNearEnd,
() => {
@@ -137,6 +109,15 @@ const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
onResize.cancel()
onResize.cancel() // Clear pending debounced calls
})
</script>
<style scoped>
.scroll-container {
height: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--dialog-surface) transparent;
}
</style>

View File

@@ -1,43 +0,0 @@
<template>
<div
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{
background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
}"
>
{{ letter }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
workspaceName: string
}>()
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {
const seed = letter.value.charCodeAt(0)
function mulberry32(a: number) {
return function () {
let t = (a += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const rand = mulberry32(seed)
const hue1 = Math.floor(rand() * 360)
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
const sat = 65 + Math.floor(rand() * 20)
const light = 55 + Math.floor(rand() * 15)
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
})
</script>

View File

@@ -1,26 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const statusBadgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-base-foreground',
secondary: 'bg-secondary-background text-base-foreground',
warn: 'bg-warning-background text-base-background',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-xxxs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>

View File

@@ -4,12 +4,7 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key"
@@ -43,15 +38,7 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { computed } from 'vue'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
</script>
@@ -68,27 +55,4 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({
}))
describe('SettingItem', () => {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
const mountComponent = (props: any, options = {}): any => {
return mount(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
@@ -32,7 +32,6 @@ describe('SettingItem', () => {
'i-material-symbols:experiment-outline': true
}
},
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
props,
...options
})
@@ -49,9 +48,8 @@ describe('SettingItem', () => {
}
})
// Check the FormItem component's item prop for the options
const formItem = wrapper.findComponent({ name: 'FormItem' })
const options = formItem.props('item').options
// Get the options property of the FormItem
const options = wrapper.vm.formItem.options
expect(options).toEqual([
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
@@ -69,8 +67,7 @@ describe('SettingItem', () => {
})
// Should not throw an error and tooltip should be preserved as-is
const formItem = wrapper.findComponent({ name: 'FormItem' })
expect(formItem.props('item').tooltip).toBe(
expect(wrapper.vm.formItem.tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})

View File

@@ -12,7 +12,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
import UsageLogsTable from './UsageLogsTable.vue'
@@ -20,7 +19,7 @@ import UsageLogsTable from './UsageLogsTable.vue'
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
loading: boolean
error: string | null
events: Partial<AuditLog>[]
events: any[]
pagination: {
page: number
limit: number

View File

@@ -1,11 +0,0 @@
<template>
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
</script>

View File

@@ -1,163 +0,0 @@
<template>
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs :value="activeTab" @update:value="setActiveTab">
<div class="flex w-full items-center">
<TabList class="w-full">
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
</TabList>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : ''
]"
@click="
item.command?.({
originalEvent: $event,
item
})
"
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</template>
</Menu>
</template>
</div>
<TabPanels>
<TabPanel value="plan">
<SubscriptionPanelContent />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showEditWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { activeTab, setActiveTab, permissions, uiConfig } = useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {
showLeaveWorkspaceDialog()
}
function handleDeleteWorkspace() {
showDeleteWorkspaceDialog()
}
function handleEditWorkspace() {
showEditWorkspaceDialog()
}
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
// Use workspace's own subscription status, not the global isActiveSubscription
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isWorkspaceSubscribed.value
)
const deleteTooltip = computed(() => {
if (!isDeleteDisabled.value) return null
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
return tooltipKey ? t(tooltipKey) : null
})
const menuItems = computed(() => {
const items = []
// Add edit option for owners
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: handleEditWorkspace
})
}
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value
? 'text-danger/50 cursor-not-allowed'
: 'text-danger',
disabled: isDeleteDisabled.value,
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
})
} else if (action === 'leave') {
items.push({
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: handleLeaveWorkspace
})
}
return items
})
onMounted(() => {
setActiveTab(defaultTab)
})
</script>

View File

@@ -1,19 +0,0 @@
<template>
<div class="flex items-center gap-2">
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
</script>

View File

@@ -1,5 +1,3 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
@@ -65,7 +63,7 @@ describe('ApiKeyForm', () => {
mockLoading.mockReset()
})
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
const mountComponent = (props: any = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],

View File

@@ -112,10 +112,8 @@ describe('SignInForm', () => {
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
@@ -140,10 +138,7 @@ describe('SignInForm', () => {
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
@@ -166,10 +161,7 @@ describe('SignInForm', () => {
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
@@ -189,10 +181,7 @@ describe('SignInForm', () => {
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
@@ -265,17 +254,12 @@ describe('SignInForm', () => {
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
@@ -289,17 +273,12 @@ describe('SignInForm', () => {
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)

View File

@@ -1,113 +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('workspacePanel.createWorkspaceDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="workspaceName"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
"
@keydown.enter="isValidName && onCreate()"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidName"
@click="onCreate"
>
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
</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 { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm?: (name: string) => void | Promise<void>
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'create-workspace' })
}
async function onCreate() {
if (!isValidName.value) return
loading.value = true
try {
const name = workspaceName.value.trim()
// Call optional callback if provided
await onConfirm?.(name)
dialogStore.closeDialog({ key: 'create-workspace' })
// Create workspace and switch to it (triggers reload internally)
await workspaceStore.createWorkspace(name)
} catch (error) {
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,89 +0,0 @@
<template>
<div
class="flex w-full max-w-[360px] 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('workspacePanel.deleteDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
workspaceName
? $t('workspacePanel.deleteDialog.messageWithName', {
name: workspaceName
})
: $t('workspacePanel.deleteDialog.message')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onDelete">
{{ $t('g.delete') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { workspaceId, workspaceName } = defineProps<{
workspaceId?: string
workspaceName?: string
}>()
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'delete-workspace' })
}
async function onDelete() {
loading.value = true
try {
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
await workspaceStore.deleteWorkspace(workspaceId)
dialogStore.closeDialog({ key: 'delete-workspace' })
window.location.reload()
} catch (error) {
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,104 +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('workspacePanel.editWorkspaceDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="newWorkspaceName"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
@keydown.enter="isValidName && onSave()"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidName"
@click="onSave"
>
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
</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 { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'edit-workspace' })
}
async function onSave() {
if (!isValidName.value) return
loading.value = true
try {
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
dialogStore.closeDialog({ key: 'edit-workspace' })
toast.add({
severity: 'success',
summary: t('workspacePanel.toast.workspaceUpdated.title'),
detail: t('workspacePanel.toast.workspaceUpdated.message'),
life: 5000
})
} catch (error) {
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,78 +0,0 @@
<template>
<div
class="flex w-full max-w-[360px] 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('workspacePanel.leaveDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.leaveDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onLeave">
{{ $t('workspacePanel.leaveDialog.leave') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'leave-workspace' })
}
async function onLeave() {
loading.value = true
try {
// leaveWorkspace() handles switching to personal workspace internally and reloads
await workspaceStore.leaveWorkspace()
dialogStore.closeDialog({ key: 'leave-workspace' })
window.location.reload()
} catch (error) {
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -5,32 +5,25 @@ import { cn } from '@/utils/tailwindUtil'
import TransitionCollapse from './TransitionCollapse.vue'
const {
disabled,
label,
enableEmptyState,
tooltip,
class: className
} = defineProps<{
const props = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
class?: string
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
const isExpanded = computed(() => !isCollapse.value && !disabled)
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
const tooltipConfig = computed(() => {
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
if (!props.tooltip) return undefined
return { value: props.tooltip, showDelay: 1000 }
})
</script>
<template>
<div :class="cn('flex flex-col bg-comfy-menu-bg', className)">
<div class="flex flex-col bg-comfy-menu-bg">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
>

View File

@@ -43,7 +43,7 @@ const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
const component = getComponent(widget.type)
const component = getComponent(widget.type, widget.name)
return component || WidgetLegacy
})

View File

@@ -219,7 +219,7 @@ const extraMenuItems = computed(() => [
{
key: 'settings',
label: t('g.settings'),
icon: 'icon-[lucide--settings]',
icon: 'mdi mdi-cog-outline',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'icon-[lucide--puzzle]',
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
}
])

View File

@@ -5,11 +5,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const canvasStore = useCanvasStore()
function toggleLinearMode() {
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
}
</script>
<template>
<div class="p-1 bg-secondary-background rounded-lg w-10">
@@ -17,7 +12,7 @@ function toggleLinearMode() {
size="icon"
:title="t('linearMode.linearMode')"
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
@click="toggleLinearMode"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
>
<i class="icon-[lucide--panels-top-left]" />
</Button>
@@ -25,7 +20,7 @@ function toggleLinearMode() {
size="icon"
:title="t('linearMode.graphMode')"
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
@click="toggleLinearMode"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
>
<i class="icon-[comfy--workflow]" />
</Button>

View File

@@ -1,113 +0,0 @@
<template>
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
</div>
<!-- Assets Header -->
<div
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<!-- Assets Grid -->
<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="gridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@output-count-click="emit('output-count-click', item.asset)"
/>
</template>
</VirtualGrid>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
const {
assets,
isSelected,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'zoom', asset: AssetItem): void
(e: 'output-count-click', asset: AssetItem): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}
</script>

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
@@ -114,7 +114,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { isActiveJobState } from '@/utils/queueUtil'
import type { JobState } from '@/types/queue'
import {
formatDuration,
formatSize,
@@ -172,6 +172,12 @@ const listGridStyle = {
gap: '0.5rem'
}
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}

View File

@@ -105,19 +105,30 @@
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
<VirtualGrid
v-else
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item"
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
@click="handleAssetSelect(item)"
@context-menu="handleAssetContextMenu"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
/>
</template>
</VirtualGrid>
</div>
</template>
<template #footer>
@@ -202,7 +213,6 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
@@ -210,14 +220,15 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
@@ -247,7 +258,6 @@ interface JobOutputItem {
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
@@ -294,6 +304,9 @@ const formattedExecutionTime = computed(() => {
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -394,14 +407,14 @@ const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
(!isListView.value || activeJobsCount.value === 0)
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
(!isListView.value || activeJobsCount.value === 0)
)
watch(displayAssets, (newAssets) => {
@@ -443,6 +456,14 @@ const galleryItems = computed(() => {
})
})
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {

View File

@@ -1,4 +1,4 @@
<!-- A button that shows workspace icon (Cloud) or user avatar -->
<!-- A button that shows current authenticated user's avatar -->
<template>
<div>
<Button
@@ -16,16 +16,7 @@
)
"
>
<WorkspaceProfilePic
v-if="showWorkspaceIcon"
:workspace-name="workspaceName"
:class="compact && 'size-full'"
/>
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'size-full'"
/>
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
@@ -36,65 +27,38 @@
:show-arrow="false"
:pt="{
root: {
class: 'rounded-lg w-80'
class: 'rounded-lg'
}
}"
>
<!-- Workspace mode: workspace-aware popover -->
<CurrentUserPopoverWorkspace
v-if="teamWorkspacesEnabled"
@close="closePopover"
/>
<!-- Legacy mode: original popover -->
<CurrentUserPopover v-else @close="closePopover" />
<CurrentUserPopover @close="closePopover" />
</Popover>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Popover from 'primevue/popover'
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
)
const { showArrow = true, compact = false } = defineProps<{
showArrow?: boolean
compact?: boolean
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
const workspaceName = computed(() => {
if (!showWorkspaceIcon.value) return ''
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
return workspaceName.value
})
const popover = ref<InstanceType<typeof Popover> | null>(null)
const closePopover = () => {
popover.value?.hide()
}

View File

@@ -1,337 +0,0 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
<!-- Workspace Selector -->
<div
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
@click="toggleWorkspaceSwitcher"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<WorkspaceProfilePic
class="size-6 shrink-0 text-xs"
:workspace-name="workspaceName"
/>
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
<div
v-if="workspaceTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ workspaceTierName }}
</div>
<span v-else class="shrink-0 text-xs text-muted-foreground">
{{ $t('workspaceSwitcher.subscribe') }}
</span>
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
<Popover
ref="workspaceSwitcherPopover"
append-to="body"
:pt="{
content: {
class: 'p-0'
}
}"
>
<WorkspaceSwitcherPopover
@select="workspaceSwitcherPopover?.hide()"
@create="handleCreateWorkspace"
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button (disabled until billing is ready) -->
<SubscribeButton
v-else
disabled
:fluid="false"
:label="$t('workspaceSwitcher.subscribe')"
size="sm"
variant="gradient"
/>
</div>
<Divider class="mx-0 my-2" />
</template>
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
v-if="showPlansAndPricing"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="plans-pricing-menu-item"
@click="handleOpenPlansAndPricing"
>
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.plansAndPricing')
}}</span>
<span
v-if="canUpgrade"
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
>
{{ $t('subscription.upgrade') }}
</span>
</div>
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
<div
v-if="showManagePlan"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"
>
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.managePlan')
}}</span>
</div>
<!-- Partner Nodes Pricing (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Workspace Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="workspace-settings-menu-item"
@click="handleOpenWorkspaceSettings"
>
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.workspaceSettings')
}}</span>
</div>
<!-- Account Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.accountSettings')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Logout (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
@click="handleLogout"
>
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('auth.signOut.signOut')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed,
subscriptionPlan
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
close: []
}>()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog()
const { t } = useI18n()
const displayedCredits = computed(() =>
isWorkspaceSubscribed.value ? totalCredits.value : '0'
)
// Workspace subscription tier name (not user tier)
const workspaceTierName = computed(() => {
if (!isWorkspaceSubscribed.value) return null
if (!subscriptionPlan.value) return null
// Convert plan to display name
if (subscriptionPlan.value === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (subscriptionPlan.value === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
})
const canUpgrade = computed(() => {
// PRO is currently the only/highest tier, so no upgrades available
// This will need updating when additional tiers are added
return false
})
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleOpenWorkspaceSettings = () => {
dialogService.showSettingsDialog('workspace')
emit('close')
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.show()
emit('close')
}
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace')
} else {
dialogService.showSettingsDialog('credits')
}
emit('close')
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')
}
const handleCreateWorkspace = () => {
workspaceSwitcherPopover.value?.hide()
dialogService.showCreateWorkspaceDialog()
emit('close')
}
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
onMounted(() => {
void authActions.fetchBalance()
})
</script>

View File

@@ -1,166 +0,0 @@
<template>
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
<div class="flex flex-col overflow-y-auto">
<!-- Loading state -->
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
<div
v-for="i in 2"
:key="i"
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
>
<div class="size-8 rounded-full bg-secondary-background" />
<div class="flex flex-1 flex-col gap-1">
<div class="h-4 w-24 rounded bg-secondary-background" />
<div class="h-3 w-16 rounded bg-secondary-background" />
</div>
</div>
</div>
<!-- Workspace list -->
<template v-else>
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
<div class="border-b border-border-default p-2">
<div
:class="
cn(
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
'hover:bg-secondary-background-hover',
isCurrentWorkspace(workspace) && 'bg-secondary-background'
)
"
>
<button
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span
v-if="workspace.type !== 'personal'"
class="text-sm text-muted-foreground"
>
{{ getRoleLabel(workspace.role) }}
</span>
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
/>
</button>
</div>
</div>
</template>
</template>
<!-- <Divider class="mx-0 my-0" /> -->
<!-- Create workspace button -->
<div class="px-2 py-2">
<div
:class="
cn(
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
canCreateWorkspace
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'cursor-default'
)
"
@click="canCreateWorkspace && handleCreateWorkspace()"
>
<div
:class="
cn(
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
!canCreateWorkspace && 'opacity-50'
)
"
>
<i class="pi pi-plus text-sm text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<span
v-if="canCreateWorkspace"
class="text-sm text-muted-foreground"
>
{{ $t('workspaceSwitcher.createWorkspace') }}
</span>
<span v-else class="text-sm text-muted-foreground">
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type {
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
}
const emit = defineEmits<{
select: [workspace: AvailableWorkspace]
create: []
}>()
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
storeToRefs(workspaceStore)
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
workspaces.value.map((w) => ({
id: w.id,
name: w.name,
type: w.type,
role: w.role
}))
)
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')
return ''
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
if (success) {
emit('select', workspace)
}
}
function handleCreateWorkspace() {
emit('create')
}
</script>

View File

@@ -1,19 +0,0 @@
# UI Component Guidelines
## Adding New Components
```bash
pnpm dlx shadcn-vue@latest add <component-name> --yes
```
After adding, create `ComponentName.stories.ts` with Default, Disabled, and variant stories.
## Reka UI Wrapper Components
- Use reactive props destructuring with rest: `const { class: className, ...restProps } = defineProps<Props>()`
- Use `useForwardProps(restProps)` for prop forwarding, or `computed()` if adding defaults
- Import siblings directly (`./Component.vue`), not from barrel (`'.'`)
- Use `cn()` for class merging with `className`
- Use Iconify icons: `<i class="icon-[lucide--check]" />`
- Use design tokens: `bg-secondary-background`, `text-muted-foreground`, `border-border-default`
- Tailwind 4 CSS variables use parentheses: `h-(--my-var)` not `h-[--my-var]`

View File

@@ -26,8 +26,7 @@ export const buttonVariants = cva({
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-8',
'icon-sm': 'size-5 p-0',
unset: ''
'icon-sm': 'size-5 p-0'
}
},

View File

@@ -1,261 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Select from './Select.vue'
import SelectContent from './SelectContent.vue'
import SelectGroup from './SelectGroup.vue'
import SelectItem from './SelectItem.vue'
import SelectLabel from './SelectLabel.vue'
import SelectSeparator from './SelectSeparator.vue'
import SelectTrigger from './SelectTrigger.vue'
import SelectValue from './SelectValue.vue'
const meta = {
title: 'Components/Select',
component: Select,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text',
description: 'Selected value'
},
disabled: {
control: 'boolean',
description: 'When true, disables the select'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref(args.modelValue || '')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const WithPlaceholder: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Choose an option..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const Disabled: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('apple')
return { value, args }
},
template: `
<Select v-model="value" disabled>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>
`
})
}
export const WithGroups: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a model type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Checkpoints</SelectLabel>
<SelectItem value="sd15">SD 1.5</SelectItem>
<SelectItem value="sdxl">SDXL</SelectItem>
<SelectItem value="flux">Flux</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>LoRAs</SelectLabel>
<SelectItem value="lora-style">Style LoRA</SelectItem>
<SelectItem value="lora-character">Character LoRA</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="vae">VAE</SelectItem>
<SelectItem value="embedding">Embedding</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const Scrollable: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
const items = Array.from({ length: 20 }, (_, i) => ({
value: `item-${i + 1}`,
label: `Option ${i + 1}`
}))
return { value, items, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in items"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<div class="space-y-4">
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-32">
<SelectValue placeholder="Small" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">A</SelectItem>
<SelectItem value="b">B</SelectItem>
<SelectItem value="c">C</SelectItem>
</SelectContent>
</Select>
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-full">
<SelectValue placeholder="Full width select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
</div>
`
}),
args: {
disabled: false
}
}

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@@ -1,73 +0,0 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import SelectScrollDownButton from './SelectScrollDownButton.vue'
import SelectScrollUpButton from './SelectScrollUpButton.vue'
defineOptions({
inheritAttrs: false
})
const {
position = 'popper',
class: className,
...restProps
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = computed(() => ({
position,
...restProps
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'relative z-3000 max-h-96 min-w-32 overflow-hidden',
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'scrollbar-custom flex flex-col gap-0',
position === 'popper' &&
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)'
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectGroupProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
<slot />
</SelectGroup>
</template>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectItemProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectItem
v-bind="restProps"
:class="
cn(
'relative flex w-full cursor-pointer select-none items-center justify-between',
'gap-3 rounded px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)
"
>
<SelectItemText class="truncate">
<slot />
</SelectItemText>
<SelectItemIndicator class="flex shrink-0 items-center justify-center">
<i class="icon-[lucide--check] text-base-foreground" aria-hidden="true" />
</SelectItemIndicator>
</SelectItem>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import { SelectLabel } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectLabelProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectLabel
v-bind="restProps"
:class="
cn(
'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground',
className
)
"
>
<slot />
</SelectLabel>
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import { SelectScrollDownButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollDownButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-down]" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import { SelectScrollUpButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollUpButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-up]" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import { SelectSeparator } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectSeparator
v-bind="restProps"
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
/>
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import { SelectIcon, SelectTrigger } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectTriggerProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectTrigger
v-bind="restProps"
:class="
cn(
'flex h-10 w-full cursor-pointer select-none items-center justify-between',
'rounded-lg px-4 py-2 text-sm',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus:border-node-component-border focus:outline-none',
'data-[placeholder]:text-muted-foreground',
'disabled:cursor-not-allowed disabled:opacity-60',
'[&>span]:truncate',
className
)
"
>
<slot />
<SelectIcon as-child>
<i class="icon-[lucide--chevron-down] shrink-0 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {
<i
v-if="!disabled && !isEditing"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
/>
</TagsInputRoot>
</template>

View File

@@ -23,11 +23,6 @@ const showInput = computed(() => isEditing.value || isEmpty)
const { forwardRef, currentElement } = useForwardExpose()
const registerFocus = inject(tagsInputFocusKey, undefined)
function handleEscape() {
currentElement.value?.blur()
isEditing.value = false
}
onMounted(() => {
registerFocus?.(() => currentElement.value?.focus())
})
@@ -49,6 +44,5 @@ onUnmounted(() => {
className
)
"
@keydown.escape.stop="handleEscape"
/>
</template>

View File

@@ -1,36 +1,46 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative"
@keydown.esc.capture="handleEscape"
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
<Button
v-show="!isRightPanelOpen && hasRightPanel"
size="lg"
:class="
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
})
"
@click="toggleRightPanel"
>
<div
class="grid h-full w-full transition-[grid-template-columns] duration-300 ease-out"
:style="gridStyle"
<i class="icon-[lucide--panel-right]" />
</Button>
<Button
size="lg"
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
<div class="flex h-full w-full">
<Transition name="slide-panel">
<nav
class="h-full overflow-hidden"
:inert="!showLeftPanel"
:aria-hidden="!showLeftPanel"
v-if="$slots.leftPanel && showLeftPanel"
:class="[
PANEL_SIZES.width,
PANEL_SIZES.minWidth,
PANEL_SIZES.maxWidth
]"
>
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
<slot name="leftPanel" />
</div>
<slot name="leftPanel"></slot>
</nav>
</Transition>
<div class="flex flex-col bg-base-background overflow-hidden">
<div class="flex-1 flex bg-base-background">
<div class="flex h-full w-full flex-col">
<header
v-if="$slots.header"
class="w-full h-18 px-6 flex items-center justify-between gap-2"
>
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile"
size="icon"
:aria-label="
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
"
@click="toggleLeftPanel"
>
<Button v-if="!notMobile" size="icon" @click="toggleLeftPanel">
<i
:class="
cn(
@@ -41,34 +51,32 @@
"
/>
</Button>
<slot name="header" />
<slot name="header"></slot>
</div>
<slot name="header-right-area" />
<template v-if="!isRightPanelOpen">
<slot name="header-right-area"></slot>
<div
:class="
cn(
'flex justify-end gap-2 w-0',
hasRightPanel && !isRightPanelOpen ? 'min-w-22' : 'min-w-10'
)
"
>
<Button
v-if="hasRightPanel"
v-if="isRightPanelOpen && hasRightPanel"
size="lg"
class="w-10 p-0"
:aria-label="t('g.showRightPanel')"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right] size-4" />
<i class="icon-[lucide--panel-right-close]" />
</Button>
<Button
size="lg"
class="w-10"
:aria-label="t('g.closeDialog')"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
</template>
</div>
</header>
<main class="flex min-h-0 flex-1 flex-col">
<slot name="contentFilter" />
<!-- Fallback title bar when no leftPanel is provided -->
<slot name="contentFilter"></slot>
<h2
v-if="!hasLeftPanel"
v-if="!$slots.leftPanel"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
@@ -76,83 +84,45 @@
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content" />
<slot name="content"></slot>
</div>
</main>
</div>
<aside
v-if="hasRightPanel"
class="overflow-hidden"
:inert="!isRightPanelOpen"
:aria-hidden="!isRightPanelOpen"
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
>
<div
class="min-w-72 w-72 flex flex-col bg-modal-panel-background h-full"
>
<header
data-component-id="RightPanelHeader"
class="flex h-18 shrink-0 items-center gap-2 px-6"
>
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
{{ rightPanelTitle }}
</h2>
<div v-else class="flex-1">
<slot name="rightPanelHeaderTitle" />
</div>
<slot name="rightPanelHeaderActions" />
<Button
size="lg"
class="w-10 p-0"
:aria-label="t('g.hideRightPanel')"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right-close] size-4" />
</Button>
<Button
size="lg"
class="w-10 p-0"
:aria-label="t('g.closeDialog')"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
</header>
<div class="min-h-0 flex-1 overflow-y-auto">
<slot name="rightPanel" />
</div>
</div>
<slot name="rightPanel"></slot>
</aside>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { computed, inject, ref, useSlots, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { contentTitle, rightPanelTitle } = defineProps<{
const { contentTitle } = defineProps<{
contentTitle: string
rightPanelTitle?: string
}>()
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
const slots = useSlots()
const hasLeftPanel = computed(() => !!slots.leftPanel)
const hasRightPanel = computed(() => !!slots.rightPanel)
const BREAKPOINTS = { md: 880 }
const PANEL_SIZES = {
width: 'w-1/3',
minWidth: 'min-w-40',
maxWidth: 'max-w-56'
}
const slots = useSlots()
const closeDialog = inject(OnCloseKey, () => {})
const breakpoints = useBreakpoints(BREAKPOINTS)
@@ -161,6 +131,8 @@ const notMobile = breakpoints.greater('md')
const isLeftPanelOpen = ref<boolean>(true)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)
watch(notMobile, (isDesktop) => {
if (!isDesktop) {
mobileMenuOpen.value = false
@@ -174,12 +146,6 @@ const showLeftPanel = computed(() => {
return shouldShow
})
const gridStyle = computed(() => ({
gridTemplateColumns: hasRightPanel.value
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
}))
const toggleLeftPanel = () => {
if (notMobile.value) {
isLeftPanelOpen.value = !isLeftPanelOpen.value
@@ -191,23 +157,6 @@ const toggleLeftPanel = () => {
const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value
}
function handleEscape(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return
}
if (isRightPanelOpen.value) {
event.stopPropagation()
isRightPanelOpen.value = false
}
}
</script>
<style scoped>
.base-widget-layout {
@@ -222,4 +171,28 @@ function handleEscape(event: KeyboardEvent) {
max-width: 1724px;
}
}
/* Fade transition for buttons */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide transition for left panel */
.slide-panel-enter-active,
.slide-panel-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-enter-from,
.slide-panel-leave-to {
transform: translateX(-100%);
}
</style>

View File

@@ -5,7 +5,7 @@
disabled: !isOverflowing,
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'
@@ -15,32 +15,25 @@
@mouseenter="checkOverflow"
@click="onClick"
>
<NavIcon v-if="icon" :icon="icon" />
<div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
<span ref="textRef" class="min-w-0 truncate">
<slot />
<slot></slot>
</span>
<StatusBadge
v-if="badge !== undefined"
:label="String(badge)"
severity="contrast"
variant="circle"
class="ml-auto"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'
const { icon, badge, active, onClick } = defineProps<{
const { icon, active, onClick } = defineProps<{
icon: NavItemData['icon']
badge?: NavItemData['badge']
active?: boolean
onClick: () => void
}>()

View File

@@ -22,7 +22,6 @@
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
@@ -33,7 +32,6 @@
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
>

View File

@@ -77,7 +77,6 @@ export interface VueNodeData {
outputs?: INodeOutputSlot[]
resizable?: boolean
shape?: number
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
@@ -315,8 +314,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape,
showAdvanced: node.showAdvanced
shape: node.shape
}
}
@@ -570,13 +568,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? propertyEvent.newValue
: undefined
})
break
case 'showAdvanced':
vueNodeData.set(nodeId, {
...currentData,
showAdvanced: Boolean(propertyEvent.newValue)
})
break
}
}
},

View File

@@ -73,14 +73,6 @@ export const useNodeBadge = () => {
onMounted(() => {
const nodePricing = useNodePricing()
watch(
() => nodePricing.pricingRevision.value,
() => {
if (!showApiPricingBadge.value) return
app.canvas?.setDirty(true, true)
}
)
extensionStore.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
@@ -119,16 +111,17 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
// Get the pricing function to determine if this node has dynamic pricing
const pricingConfig = nodePricing.getNodePricingConfig(node)
const hasDynamicPricing =
!!pricingConfig &&
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
typeof pricingConfig?.displayPrice === 'function'
let creditsBadge
const createBadge = () => {
const price = nodePricing.getNodeDisplayPrice(node)
return priceBadge.getCreditsBadge(price)
}
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
// (We no longer rely on it to hold the current badge value.)
if (hasDynamicPricing) {
// For dynamic pricing nodes, use computed that watches widget changes
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
@@ -140,63 +133,13 @@ export const useNodeBadge = () => {
triggerCanvasRedraw: true
})
// Ensure watchers are installed; ignore the returned value.
// (This call is what registers the widget listeners in most implementations.)
computedWithWidgetWatch(() => 0)
// Hook into connection changes to trigger price recalculation
// This handles both connect and disconnect in VueNodes mode
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
const inputGroupPrefixes =
pricingConfig?.depends_on?.input_groups ?? []
const hasRelevantInputs =
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
if (hasRelevantInputs) {
const originalOnConnectionsChange = node.onConnectionsChange
node.onConnectionsChange = function (
type,
slotIndex,
isConnected,
link,
ioSlot
) {
originalOnConnectionsChange?.call(
this,
type,
slotIndex,
isConnected,
link,
ioSlot
)
// Only trigger if this input affects pricing
const inputName = ioSlot?.name
if (!inputName) return
const isRelevantInput =
relevantInputs.includes(inputName) ||
inputGroupPrefixes.some((prefix) =>
inputName.startsWith(prefix + '.')
)
if (isRelevantInput) {
nodePricing.triggerPriceRecalculation(node)
}
}
}
creditsBadge = computedWithWidgetWatch(createBadge)
} else {
// For static pricing nodes, use regular computed
creditsBadge = computed(createBadge)
}
let lastLabel = nodePricing.getNodeDisplayPrice(node)
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
const creditsBadgeGetter: () => LGraphBadge = () => {
const label = nodePricing.getNodeDisplayPrice(node)
if (label !== lastLabel) {
lastLabel = label
lastBadge = priceBadge.getCreditsBadge(label)
}
return lastBadge
}
node.badges.push(creditsBadgeGetter)
node.badges.push(() => creditsBadge.value)
}
},
init() {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More