Merge branch 'main' into feat/survey-eligibility

This commit is contained in:
Christian Byrne
2026-01-23 18:48:21 -08:00
committed by GitHub
248 changed files with 15168 additions and 7260 deletions

View File

@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
echo " pnpm test:unit - Run unit tests" echo " pnpm test:unit - Run unit tests"
echo " pnpm typecheck - Run TypeScript checks" echo " pnpm typecheck - Run TypeScript checks"
echo " pnpm lint - Run ESLint" echo " pnpm lint - Run ESLint"
echo " pnpm format - Format code with Prettier" echo " pnpm format - Format code with oxfmt"
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo "1. Run 'pnpm dev' to start developing" echo "1. Run 'pnpm dev' to start developing"

View File

@@ -42,7 +42,7 @@ jobs:
- name: Run Stylelint with auto-fix - name: Run Stylelint with auto-fix
run: pnpm stylelint:fix run: pnpm stylelint:fix
- name: Run Prettier with auto-format - name: Run oxfmt with auto-format
run: pnpm format run: pnpm format
- name: Check for changes - name: Check for changes
@@ -60,7 +60,7 @@ jobs:
git config --local user.email "action@github.com" git config --local user.email "action@github.com"
git config --local user.name "GitHub Action" git config --local user.name "GitHub Action"
git add . git add .
git commit -m "[automated] Apply ESLint and Prettier fixes" git commit -m "[automated] Apply ESLint and Oxfmt fixes"
git push git push
- name: Final validation - name: Final validation
@@ -80,7 +80,7 @@ jobs:
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting' body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
}) })
- name: Comment on PR about manual fix needed - name: Comment on PR about manual fix needed

View File

@@ -1,7 +1,7 @@
// This file is intentionally kept in CommonJS format (.cjs) // This file is intentionally kept in CommonJS format (.cjs)
// to resolve compatibility issues with dependencies that require CommonJS. // to resolve compatibility issues with dependencies that require CommonJS.
// Do not convert this file to ESModule format unless all dependencies support it. // Do not convert this file to ESModule format unless all dependencies support it.
const { defineConfig } = require('@lobehub/i18n-cli'); const { defineConfig } = require('@lobehub/i18n-cli')
module.exports = defineConfig({ module.exports = defineConfig({
modelName: 'gpt-4.1', modelName: 'gpt-4.1',
@@ -10,7 +10,19 @@ module.exports = defineConfig({
entry: 'src/locales/en', entry: 'src/locales/en',
entryLocale: 'en', entryLocale: 'en',
output: 'src/locales', output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'], outputLocales: [
'zh',
'zh-TW',
'ru',
'ja',
'ko',
'fr',
'es',
'ar',
'tr',
'pt-BR',
'fa'
],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face. reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'. 'latent' is the short form of 'latent space'.
'mask' is in the context of image processing. 'mask' is in the context of image processing.
@@ -26,4 +38,4 @@ module.exports = defineConfig({
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate. - Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications. - Maintain consistency with terminology used in Persian software and design applications.
` `
}); })

20
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"ignorePatterns": [
"packages/registry-types/src/comfyRegistryTypes.ts",
"src/types/generatedManagerTypes.ts",
"**/*.md",
"**/*.json",
"**/*.css",
"**/*.yaml",
"**/*.yml",
"**/*.html",
"**/*.svg",
"**/*.xml"
]
}

View File

@@ -1,2 +0,0 @@
packages/registry-types/src/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts

View File

@@ -1,11 +0,0 @@
{
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
}

View File

@@ -1,25 +1,22 @@
{ {
"recommendations": [ "recommendations": [
"antfu.vite",
"austenc.tailwind-docs", "austenc.tailwind-docs",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint", "davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens", "eamodio.gitlens",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"github.vscode-github-actions", "github.vscode-github-actions",
"github.vscode-pull-request-github", "github.vscode-pull-request-github",
"hbenl.vscode-test-explorer", "hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally", "lokalise.i18n-ally",
"ms-playwright.playwright", "ms-playwright.playwright",
"oxc.oxc-vscode",
"sonarsource.sonarlint-vscode",
"vitest.explorer", "vitest.explorer",
"vue.volar", "vue.volar",
"sonarsource.sonarlint-vscode", "wix.vscode-import-cost"
"deque-systems.vscode-axe-linter",
"kisstkondoros.vscode-codemetrics",
"donjayamanne.githistory",
"wix.vscode-import-cost",
"prograhammer.tslint-vue",
"antfu.vite"
] ]
} }

View File

@@ -27,10 +27,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob)
- Build output: `dist/` - Build output: `dist/`
- Configs - Configs
- `vite.config.mts` - `vite.config.mts`
- `vitest.config.ts`
- `playwright.config.ts` - `playwright.config.ts`
- `eslint.config.ts` - `eslint.config.ts`
- `.prettierrc` - `.oxfmtrc.json`
- `.oxlintrc.json`
- etc. - etc.
## Monorepo Architecture ## Monorepo Architecture
@@ -46,7 +46,7 @@ The project uses **Nx** for build orchestration and task management
- `pnpm test:unit`: Run Vitest unit tests - `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`) - `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint) - `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: Prettier - `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm typecheck`: Vue TSC type checking - `pnpm typecheck`: Vue TSC type checking
- `pnpm storybook`: Start Storybook development server - `pnpm storybook`: Start Storybook development server
@@ -72,7 +72,7 @@ The project uses **Nx** for build orchestration and task management
- Composition API only - Composition API only
- Tailwind 4 styling - Tailwind 4 styling
- Avoid `<style>` blocks - Avoid `<style>` blocks
- Style: (see `.prettierrc`) - Style: (see `.oxfmtrc.json`)
- Indent 2 spaces - Indent 2 spaces
- single quotes - single quotes
- no trailing semicolons - no trailing semicolons

View File

@@ -79,48 +79,15 @@ export class SubgraphSlotReference {
const node = const node =
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) { if (!node) {
throw new Error(`No ${type} node found in subgraph`) throw new Error(`No ${type} node found in subgraph`)
} }
// Calculate position for next available slot
// const nextSlotIndex = slots?.length || 0
// const slotHeight = 20
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
// Find last slot position
const lastSlot = slots.at(-1)
let slotX: number
let slotY: number
if (lastSlot) {
// If there are existing slots, position the new one below the last one
const gapHeight = 20
slotX = lastSlot.pos[0]
slotY = lastSlot.pos[1] + gapHeight
} else {
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
if (currentGraph.slotAnchorX !== undefined) {
// The actual slot X position seems to be slotAnchorX - 10
slotX = currentGraph.slotAnchorX - 10
} else {
// Fallback: calculate from node edge
slotX =
type === 'input'
? node.pos[0] + node.size[0] - 10 // Right edge for input node
: node.pos[0] + 10 // Left edge for output node
}
// For Y position when no slots exist, use middle of node
slotY = node.pos[1] + node.size[1] / 2
}
// Convert from offset to canvas coordinates // Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([ const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
slotX, node.emptySlot.pos[0],
slotY node.emptySlot.pos[1]
]) ])
return canvasPos return canvasPos
}, },
@@ -152,8 +119,7 @@ class NodeSlotReference {
window['app'].canvas.ds.convertOffsetToCanvas(rawPos) window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float64Arrays to regular arrays for visibility // Debug logging - convert Float64Arrays to regular arrays for visibility
// eslint-disable-next-line no-console console.warn(
console.log(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`, `NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{ {
nodePos: [node.pos[0], node.pos[1]], nodePos: [node.pos[0], node.pos[1]],

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 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: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 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: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

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

View File

@@ -30,6 +30,10 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior. **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 ## Mock Patterns
### Reset all mocks at once ### Reset all mocks at once

View File

@@ -4,9 +4,7 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript' import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x' import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint' import oxlint from 'eslint-plugin-oxlint'
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows // eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
// See: https://github.com/nodejs/node/issues/58690
// Prettier is still run separately in lint-staged, so this is safe to disable
import eslintConfigPrettier from 'eslint-config-prettier' import eslintConfigPrettier from 'eslint-config-prettier'
import { configs as storybookConfigs } from 'eslint-plugin-storybook' import { configs as storybookConfigs } from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports' import unusedImports from 'eslint-plugin-unused-imports'
@@ -111,7 +109,7 @@ export default defineConfig([
tseslintConfigs.recommended, tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local // Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'], pluginVue.configs['flat/recommended'],
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault // Disables ESLint rules that conflict with formatters
eslintConfigPrettier, eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types // @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
storybookConfigs['flat/recommended'], storybookConfigs['flat/recommended'],

View File

@@ -1,25 +0,0 @@
import path from 'node:path'
export default {
'tests-ui/**': () => 'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec prettier --cache --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -1,6 +1,9 @@
import path from 'node:path' import path from 'node:path'
export default { export default {
'tests-ui/**': () =>
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles), './**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [ './**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
@@ -14,7 +17,7 @@ function formatAndEslint(fileNames: string[]) {
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f)) const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ') const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [ return [
`pnpm exec prettier --cache --write ${joinedPaths}`, `pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`, `pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}` `pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
] ]

View File

@@ -1,7 +1,7 @@
{ {
"name": "@comfyorg/comfyui-frontend", "name": "@comfyorg/comfyui-frontend",
"private": true, "private": true,
"version": "1.38.7", "version": "1.38.10",
"type": "module", "type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",
@@ -22,10 +22,8 @@
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve", "dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve", "dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools", "devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'", "format:check": "oxfmt --check",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache", "format": "oxfmt --write",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"json-schema": "tsx scripts/generate-json-schema.ts", "json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip", "knip:no-cache": "knip",
"knip": "knip --cache", "knip": "knip --cache",
@@ -63,14 +61,12 @@
"@nx/vite": "catalog:", "@nx/vite": "catalog:",
"@pinia/testing": "catalog:", "@pinia/testing": "catalog:",
"@playwright/test": "catalog:", "@playwright/test": "catalog:",
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:", "@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:", "@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:", "@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:", "@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:", "@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@trivago/prettier-plugin-sort-imports": "catalog:",
"@types/fs-extra": "catalog:", "@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:", "@types/jsdom": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
@@ -101,11 +97,11 @@
"markdown-table": "catalog:", "markdown-table": "catalog:",
"mixpanel-browser": "catalog:", "mixpanel-browser": "catalog:",
"nx": "catalog:", "nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:", "oxlint": "catalog:",
"oxlint-tsgolint": "catalog:", "oxlint-tsgolint": "catalog:",
"picocolors": "catalog:", "picocolors": "catalog:",
"postcss-html": "catalog:", "postcss-html": "catalog:",
"prettier": "catalog:",
"pretty-bytes": "catalog:", "pretty-bytes": "catalog:",
"rollup-plugin-visualizer": "catalog:", "rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:", "storybook": "catalog:",
@@ -173,6 +169,7 @@
"firebase": "catalog:", "firebase": "catalog:",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"jsonata": "catalog:",
"jsondiffpatch": "^0.6.0", "jsondiffpatch": "^0.6.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"marked": "^15.0.11", "marked": "^15.0.11",

View File

@@ -1,19 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 693 B

View File

@@ -1,20 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 763 B

View File

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

View File

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

335
pnpm-lock.yaml generated
View File

@@ -48,9 +48,6 @@ catalogs:
'@playwright/test': '@playwright/test':
specifier: ^1.57.0 specifier: ^1.57.0
version: 1.57.0 version: 1.57.0
'@prettier/plugin-oxc':
specifier: ^0.1.3
version: 0.1.3
'@primeuix/forms': '@primeuix/forms':
specifier: 0.0.2 specifier: 0.0.2
version: 0.0.2 version: 0.0.2
@@ -96,9 +93,6 @@ catalogs:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.12 specifier: ^4.1.12
version: 4.1.12 version: 4.1.12
'@trivago/prettier-plugin-sort-imports':
specifier: ^5.2.0
version: 5.2.2
'@types/fs-extra': '@types/fs-extra':
specifier: ^11.0.4 specifier: ^11.0.4
version: 11.0.4 version: 11.0.4
@@ -192,6 +186,9 @@ catalogs:
jsdom: jsdom:
specifier: ^27.4.0 specifier: ^27.4.0
version: 27.4.0 version: 27.4.0
jsonata:
specifier: ^2.1.0
version: 2.1.0
knip: knip:
specifier: ^5.75.1 specifier: ^5.75.1
version: 5.75.1 version: 5.75.1
@@ -207,6 +204,9 @@ catalogs:
nx: nx:
specifier: 22.2.6 specifier: 22.2.6
version: 22.2.6 version: 22.2.6
oxfmt:
specifier: ^0.26.0
version: 0.26.0
oxlint: oxlint:
specifier: ^1.33.0 specifier: ^1.33.0
version: 1.33.0 version: 1.33.0
@@ -222,9 +222,6 @@ catalogs:
postcss-html: postcss-html:
specifier: ^1.8.0 specifier: ^1.8.0
version: 1.8.0 version: 1.8.0
prettier:
specifier: ^3.7.4
version: 3.7.4
pretty-bytes: pretty-bytes:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
@@ -455,6 +452,9 @@ importers:
glob: glob:
specifier: ^11.0.3 specifier: ^11.0.3
version: 11.0.3 version: 11.0.3
jsonata:
specifier: 'catalog:'
version: 2.1.0
jsondiffpatch: jsondiffpatch:
specifier: ^0.6.0 specifier: ^0.6.0
version: 0.6.0 version: 0.6.0
@@ -540,9 +540,6 @@ importers:
'@playwright/test': '@playwright/test':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.57.0 version: 1.57.0
'@prettier/plugin-oxc':
specifier: 'catalog:'
version: 0.1.3
'@sentry/vite-plugin': '@sentry/vite-plugin':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.6.0 version: 4.6.0
@@ -561,9 +558,6 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.1.12(vite@8.0.0-beta.8(@types/node@24.10.4)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) version: 4.1.12(vite@8.0.0-beta.8(@types/node@24.10.4)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@trivago/prettier-plugin-sort-imports':
specifier: 'catalog:'
version: 5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)
'@types/fs-extra': '@types/fs-extra':
specifier: 'catalog:' specifier: 'catalog:'
version: 11.0.4 version: 11.0.4
@@ -654,6 +648,9 @@ importers:
nx: nx:
specifier: 'catalog:' specifier: 'catalog:'
version: 22.2.6 version: 22.2.6
oxfmt:
specifier: 'catalog:'
version: 0.26.0
oxlint: oxlint:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.33.0(oxlint-tsgolint@0.9.1) version: 1.33.0(oxlint-tsgolint@0.9.1)
@@ -666,9 +663,6 @@ importers:
postcss-html: postcss-html:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.8.0 version: 1.8.0
prettier:
specifier: 'catalog:'
version: 3.7.4
pretty-bytes: pretty-bytes:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.1.0 version: 7.1.0
@@ -2517,95 +2511,6 @@ packages:
'@one-ini/wasm@0.1.1': '@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@oxc-parser/binding-android-arm64@0.99.0':
resolution: {integrity: sha512-V4jhmKXgQQdRnm73F+r3ZY4pUEsijQeSraFeaCGng7abSNJGs76X6l82wHnmjLGFAeY00LWtjcELs7ZmbJ9+lA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.99.0':
resolution: {integrity: sha512-Rp41nf9zD5FyLZciS9l1GfK8PhYqrD5kEGxyTOA2esTLeAy37rZxetG2E3xteEolAkeb2WDkVrlxPtibeAncMg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.99.0':
resolution: {integrity: sha512-WVonp40fPPxo5Gs0POTI57iEFv485TvNKOHMwZRhigwZRhZY2accEAkYIhei9eswF4HN5B44Wybkz7Gd1Qr/5Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.99.0':
resolution: {integrity: sha512-H30bjOOttPmG54gAqu6+HzbLEzuNOYO2jZYrIq4At+NtLJwvNhXz28Hf5iEAFZIH/4hMpLkM4VN7uc+5UlNW3Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.99.0':
resolution: {integrity: sha512-0Z/Th0SYqzSRDPs6tk5lQdW0i73UCupnim3dgq2oW0//UdLonV/5wIZCArfKGC7w9y4h8TxgXpgtIyD1kKzzlQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.99.0':
resolution: {integrity: sha512-xo0wqNd5bpbzQVNpAIFbHk1xa+SaS/FGBABCd942SRTnrpxl6GeDj/s1BFaGcTl8MlwlKVMwOcyKrw/2Kdfquw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.99.0':
resolution: {integrity: sha512-u26I6LKoLTPTd4Fcpr0aoAtjnGf5/ulMllo+QUiBhupgbVCAlaj4RyXH/mvcjcsl2bVBv9E/gYJZz2JjxQWXBA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-arm64-musl@0.99.0':
resolution: {integrity: sha512-qhftDo2D37SqCEl3ZTa367NqWSZNb1Ddp34CTmShLKFrnKdNiUn55RdokLnHtf1AL5ssaQlYDwBECX7XiBWOhw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-riscv64-gnu@0.99.0':
resolution: {integrity: sha512-zxn/xkf519f12FKkpL5XwJipsylfSSnm36h6c1zBDTz4fbIDMGyIhHfWfwM7uUmHo9Aqw1pLxFpY39Etv398+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
'@oxc-parser/binding-linux-s390x-gnu@0.99.0':
resolution: {integrity: sha512-Y1eSDKDS5E4IVC7Oxw+NbYAKRmJPMJTIjW+9xOWwteDHkFqpocKe0USxog+Q1uhzalD9M0p9eXWEWdGQCMDBMQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
'@oxc-parser/binding-linux-x64-gnu@0.99.0':
resolution: {integrity: sha512-YVJMfk5cFWB8i2/nIrbk6n15bFkMHqWnMIWkVx7r2KwpTxHyFMfu2IpeVKo1ITDSmt5nBrGdLHD36QRlu2nDLg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-linux-x64-musl@0.99.0':
resolution: {integrity: sha512-2+SDPrie5f90A1b9EirtVggOgsqtsYU5raZwkDYKyS1uvJzjqHCDhG/f4TwQxHmIc5YkczdQfwvN91lwmjsKYQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-wasm32-wasi@0.99.0':
resolution: {integrity: sha512-DKA4j0QerUWSMADziLM5sAyM7V53Fj95CV9SjP77bPfEfT7MnvFKnneaRMqPK1cpzjAGiQF52OBUIKyk0dwOQA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-parser/binding-win32-arm64-msvc@0.99.0':
resolution: {integrity: sha512-EaB3AvsxqdNUhh9FOoAxRZ2L4PCRwDlDb//QXItwyOJrX7XS+uGK9B1KEUV4FZ/7rDhHsWieLt5e07wl2Ti5AQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.99.0':
resolution: {integrity: sha512-sJN1Q8h7ggFOyDn0zsHaXbP/MklAVUvhrbq0LA46Qum686P3SZQHjbATqJn9yaVEvaSKXCshgl0vQ1gWkGgpcQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxc-project/runtime@0.108.0': '@oxc-project/runtime@0.108.0':
resolution: {integrity: sha512-J1cESY4anMO4i9KtCPmCfQAzAR00Uw4SWsDPFP10CIwDMugkh34UrTKByuYKuPaHy0XAk8LlJiZJq2OLMfbuIQ==} resolution: {integrity: sha512-J1cESY4anMO4i9KtCPmCfQAzAR00Uw4SWsDPFP10CIwDMugkh34UrTKByuYKuPaHy0XAk8LlJiZJq2OLMfbuIQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -2613,9 +2518,6 @@ packages:
'@oxc-project/types@0.108.0': '@oxc-project/types@0.108.0':
resolution: {integrity: sha512-7lf13b2IA/kZO6xgnIZA88sq3vwrxWk+2vxf6cc+omwYCRTiA5e63Beqf3fz/v8jEviChWWmFYBwzfSeyrsj7Q==} resolution: {integrity: sha512-7lf13b2IA/kZO6xgnIZA88sq3vwrxWk+2vxf6cc+omwYCRTiA5e63Beqf3fz/v8jEviChWWmFYBwzfSeyrsj7Q==}
'@oxc-project/types@0.99.0':
resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==}
'@oxc-resolver/binding-android-arm-eabi@11.15.0': '@oxc-resolver/binding-android-arm-eabi@11.15.0':
resolution: {integrity: sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==} resolution: {integrity: sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==}
cpu: [arm] cpu: [arm]
@@ -2716,6 +2618,46 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@oxfmt/darwin-arm64@0.26.0':
resolution: {integrity: sha512-AAGc+8CffkiWeVgtWf4dPfQwHEE5c/j/8NWH7VGVxxJRCZFdmWcqCXprvL2H6qZFewvDLrFbuSPRCqYCpYGaTQ==}
cpu: [arm64]
os: [darwin]
'@oxfmt/darwin-x64@0.26.0':
resolution: {integrity: sha512-xFx5ijCTjw577wJvFlZEMmKDnp3HSCcbYdCsLRmC5i3TZZiDe9DEYh3P46uqhzj8BkEw1Vm1ZCWdl48aEYAzvQ==}
cpu: [x64]
os: [darwin]
'@oxfmt/linux-arm64-gnu@0.26.0':
resolution: {integrity: sha512-GubkQeQT5d3B/Jx/IiR7NMkSmXrCZcVI0BPh1i7mpFi8HgD1hQ/LbhiBKAMsMqs5bbugdQOgBEl8bOhe8JhW1g==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-arm64-musl@0.26.0':
resolution: {integrity: sha512-OEypUwK69bFPj+aa3/LYCnlIUPgoOLu//WNcriwpnWNmt47808Ht7RJSg+MNK8a7pSZHpXJ5/E6CRK/OTwFdaQ==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-x64-gnu@0.26.0':
resolution: {integrity: sha512-xO6iEW2bC6ZHyOTPmPWrg/nM6xgzyRPaS84rATy6F8d79wz69LdRdJ3l/PXlkqhi7XoxhvX4ExysA0Nf10ZZEQ==}
cpu: [x64]
os: [linux]
'@oxfmt/linux-x64-musl@0.26.0':
resolution: {integrity: sha512-Z3KuZFC+MIuAyFCXBHY71kCsdRq1ulbsbzTe71v+hrEv7zVBn6yzql+/AZcgfIaKzWO9OXNuz5WWLWDmVALwow==}
cpu: [x64]
os: [linux]
'@oxfmt/win32-arm64@0.26.0':
resolution: {integrity: sha512-3zRbqwVWK1mDhRhTknlQFpRFL9GhEB5GfU6U7wawnuEwpvi39q91kJ+SRJvJnhyPCARkjZBd1V8XnweN5IFd1g==}
cpu: [arm64]
os: [win32]
'@oxfmt/win32-x64@0.26.0':
resolution: {integrity: sha512-m8TfIljU22i9UEIkD+slGPifTFeaCwIUfxszN3E6ABWP1KQbtwSw9Ak0TdoikibvukF/dtbeyG3WW63jv9DnEg==}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.9.1': '@oxlint-tsgolint/darwin-arm64@0.9.1':
resolution: {integrity: sha512-vk+8kChWqN+F+QUOvp4/6jDTlDCzXPgYGkxdi6EOUSOmCP1ix0uYOlIi/ytH2imXmC8YfPgLR/1BhqbsuDKuew==} resolution: {integrity: sha512-vk+8kChWqN+F+QUOvp4/6jDTlDCzXPgYGkxdi6EOUSOmCP1ix0uYOlIi/ytH2imXmC8YfPgLR/1BhqbsuDKuew==}
cpu: [arm64] cpu: [arm64]
@@ -2824,10 +2766,6 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@prettier/plugin-oxc@0.1.3':
resolution: {integrity: sha512-aABz3zIRilpWMekbt1FL1JVBQrQLR8L4Td2SRctECrWSsXGTNn/G1BqNSKCdbvQS1LWstAXfqcXzDki7GAAJyg==}
engines: {node: '>=14'}
'@primeuix/forms@0.0.2': '@primeuix/forms@0.0.2':
resolution: {integrity: sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==} resolution: {integrity: sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==}
engines: {node: '>=12.11.0'} engines: {node: '>=12.11.0'}
@@ -3571,22 +3509,6 @@ packages:
'@tmcp/auth': '@tmcp/auth':
optional: true optional: true
'@trivago/prettier-plugin-sort-imports@5.2.2':
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
engines: {node: '>18.12'}
peerDependencies:
'@vue/compiler-sfc': 3.x
prettier: 2.x - 3.x
prettier-plugin-svelte: 3.x
svelte: 4.x || 5.x
peerDependenciesMeta:
'@vue/compiler-sfc':
optional: true
prettier-plugin-svelte:
optional: true
svelte:
optional: true
'@tweenjs/tween.js@23.1.3': '@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -6039,9 +5961,6 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
jest-diff@30.2.0: jest-diff@30.2.0:
resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -6132,6 +6051,10 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
jsonata@2.1.0:
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
engines: {node: '>= 8'}
jsonc-eslint-parser@2.4.0: jsonc-eslint-parser@2.4.0:
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -6889,13 +6812,14 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
oxc-parser@0.99.0:
resolution: {integrity: sha512-MpS1lbd2vR0NZn1v0drpgu7RUFu3x9Rd0kxExObZc2+F+DIrV0BOMval/RO3BYGwssIOerII6iS8EbbpCCZQpQ==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.15.0: oxc-resolver@11.15.0:
resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==} resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==}
oxfmt@0.26.0:
resolution: {integrity: sha512-UDD1wFNwfeorMm2ZY0xy1KRAAvJ5NjKBfbDmiMwGP7baEHTq65cYpC0aPP+BGHc8weXUbSZaK8MdGyvuRUvS4Q==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.9.1: oxlint-tsgolint@0.9.1:
resolution: {integrity: sha512-w1lIvUDkkiAPFyo268SFGrdh1LQ3Lcs1XShES7I4X75TliQA0os5XJ5hNZ4lYsSevqcofgEtq4xq7rBumv69iQ==} resolution: {integrity: sha512-w1lIvUDkkiAPFyo268SFGrdh1LQ3Lcs1XShES7I4X75TliQA0os5XJ5hNZ4lYsSevqcofgEtq4xq7rBumv69iQ==}
hasBin: true hasBin: true
@@ -7796,6 +7720,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinypool@2.0.0:
resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@2.0.0: tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -10677,59 +10605,10 @@ snapshots:
'@one-ini/wasm@0.1.1': {} '@one-ini/wasm@0.1.1': {}
'@oxc-parser/binding-android-arm64@0.99.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.99.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.99.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.99.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.99.0':
optional: true
'@oxc-parser/binding-wasm32-wasi@0.99.0':
dependencies:
'@napi-rs/wasm-runtime': 1.1.1
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.99.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.99.0':
optional: true
'@oxc-project/runtime@0.108.0': {} '@oxc-project/runtime@0.108.0': {}
'@oxc-project/types@0.108.0': {} '@oxc-project/types@0.108.0': {}
'@oxc-project/types@0.99.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.15.0': '@oxc-resolver/binding-android-arm-eabi@11.15.0':
optional: true optional: true
@@ -10792,6 +10671,30 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.15.0': '@oxc-resolver/binding-win32-x64-msvc@11.15.0':
optional: true optional: true
'@oxfmt/darwin-arm64@0.26.0':
optional: true
'@oxfmt/darwin-x64@0.26.0':
optional: true
'@oxfmt/linux-arm64-gnu@0.26.0':
optional: true
'@oxfmt/linux-arm64-musl@0.26.0':
optional: true
'@oxfmt/linux-x64-gnu@0.26.0':
optional: true
'@oxfmt/linux-x64-musl@0.26.0':
optional: true
'@oxfmt/win32-arm64@0.26.0':
optional: true
'@oxfmt/win32-x64@0.26.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.9.1': '@oxlint-tsgolint/darwin-arm64@0.9.1':
optional: true optional: true
@@ -10866,10 +10769,6 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@prettier/plugin-oxc@0.1.3':
dependencies:
oxc-parser: 0.99.0
'@primeuix/forms@0.0.2': '@primeuix/forms@0.0.2':
dependencies: dependencies:
'@primeuix/utils': 0.3.2 '@primeuix/utils': 0.3.2
@@ -11585,20 +11484,6 @@ snapshots:
esm-env: 1.2.2 esm-env: 1.2.2
tmcp: 1.19.0(typescript@5.9.3) tmcp: 1.19.0(typescript@5.9.3)
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
dependencies:
'@babel/generator': 7.28.5
'@babel/parser': 7.28.5
'@babel/traverse': 7.28.5
'@babel/types': 7.28.5
javascript-natural-sort: 0.7.1
lodash: 4.17.21
prettier: 3.7.4
optionalDependencies:
'@vue/compiler-sfc': 3.5.25
transitivePeerDependencies:
- supports-color
'@tweenjs/tween.js@23.1.3': {} '@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
@@ -14431,8 +14316,6 @@ snapshots:
filelist: 1.0.4 filelist: 1.0.4
minimatch: 3.1.2 minimatch: 3.1.2
javascript-natural-sort@0.7.1: {}
jest-diff@30.2.0: jest-diff@30.2.0:
dependencies: dependencies:
'@jest/diff-sequences': 30.0.1 '@jest/diff-sequences': 30.0.1
@@ -14530,6 +14413,8 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
jsonata@2.1.0: {}
jsonc-eslint-parser@2.4.0: jsonc-eslint-parser@2.4.0:
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
@@ -15531,26 +15416,6 @@ snapshots:
safe-push-apply: 1.0.0 safe-push-apply: 1.0.0
optional: true optional: true
oxc-parser@0.99.0:
dependencies:
'@oxc-project/types': 0.99.0
optionalDependencies:
'@oxc-parser/binding-android-arm64': 0.99.0
'@oxc-parser/binding-darwin-arm64': 0.99.0
'@oxc-parser/binding-darwin-x64': 0.99.0
'@oxc-parser/binding-freebsd-x64': 0.99.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.99.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.99.0
'@oxc-parser/binding-linux-arm64-gnu': 0.99.0
'@oxc-parser/binding-linux-arm64-musl': 0.99.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.99.0
'@oxc-parser/binding-linux-s390x-gnu': 0.99.0
'@oxc-parser/binding-linux-x64-gnu': 0.99.0
'@oxc-parser/binding-linux-x64-musl': 0.99.0
'@oxc-parser/binding-wasm32-wasi': 0.99.0
'@oxc-parser/binding-win32-arm64-msvc': 0.99.0
'@oxc-parser/binding-win32-x64-msvc': 0.99.0
oxc-resolver@11.15.0: oxc-resolver@11.15.0:
optionalDependencies: optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.15.0 '@oxc-resolver/binding-android-arm-eabi': 11.15.0
@@ -15574,6 +15439,19 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.15.0 '@oxc-resolver/binding-win32-ia32-msvc': 11.15.0
'@oxc-resolver/binding-win32-x64-msvc': 11.15.0 '@oxc-resolver/binding-win32-x64-msvc': 11.15.0
oxfmt@0.26.0:
dependencies:
tinypool: 2.0.0
optionalDependencies:
'@oxfmt/darwin-arm64': 0.26.0
'@oxfmt/darwin-x64': 0.26.0
'@oxfmt/linux-arm64-gnu': 0.26.0
'@oxfmt/linux-arm64-musl': 0.26.0
'@oxfmt/linux-x64-gnu': 0.26.0
'@oxfmt/linux-x64-musl': 0.26.0
'@oxfmt/win32-arm64': 0.26.0
'@oxfmt/win32-x64': 0.26.0
oxlint-tsgolint@0.9.1: oxlint-tsgolint@0.9.1:
optionalDependencies: optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.9.1 '@oxlint-tsgolint/darwin-arm64': 0.9.1
@@ -15754,7 +15632,8 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.7.4: {} prettier@3.7.4:
optional: true
pretty-bytes@7.1.0: {} pretty-bytes@7.1.0: {}
@@ -16717,6 +16596,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
tinypool@2.0.0: {}
tinyrainbow@2.0.0: {} tinyrainbow@2.0.0: {}
tinyrainbow@3.0.3: {} tinyrainbow@3.0.3: {}

View File

@@ -17,7 +17,6 @@ catalog:
'@nx/vite': 22.2.6 '@nx/vite': 22.2.6
'@pinia/testing': ^1.0.3 '@pinia/testing': ^1.0.3
'@playwright/test': ^1.57.0 '@playwright/test': ^1.57.0
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2 '@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2 '@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2 '@primeuix/utils': ^0.3.2
@@ -33,7 +32,6 @@ catalog:
'@storybook/vue3': ^10.1.9 '@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9 '@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12 '@tailwindcss/vite': ^4.1.12
'@trivago/prettier-plugin-sort-imports': ^5.2.0
'@types/fs-extra': ^11.0.4 '@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7 '@types/jsdom': ^21.1.7
'@types/node': ^24.1.0 '@types/node': ^24.1.0
@@ -64,18 +62,19 @@ catalog:
happy-dom: ^20.0.11 happy-dom: ^20.0.11
husky: ^9.1.7 husky: ^9.1.7
jiti: 2.6.1 jiti: 2.6.1
jsonata: ^2.1.0
jsdom: ^27.4.0 jsdom: ^27.4.0
knip: ^5.75.1 knip: ^5.75.1
lint-staged: ^16.2.7 lint-staged: ^16.2.7
markdown-table: ^3.0.4 markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0 mixpanel-browser: ^2.71.0
nx: 22.2.6 nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0 oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1 oxlint-tsgolint: ^0.9.1
picocolors: ^1.1.1 picocolors: ^1.1.1
pinia: ^3.0.4 pinia: ^3.0.4
postcss-html: ^1.8.0 postcss-html: ^1.8.0
prettier: ^3.7.4
pretty-bytes: ^7.1.0 pretty-bytes: ^7.1.0
primeicons: ^7.0.0 primeicons: ^7.0.0
primevue: ^4.2.5 primevue: ^4.2.5

View File

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

View File

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

View File

@@ -1,12 +1,21 @@
import { createTestingPinia } from '@pinia/testing' import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue' import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue' import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue' import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil' import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false })) const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -27,7 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
})) }))
})) }))
function createWrapper() { function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
const i18n = createI18n({ const i18n = createI18n({
legacy: false, legacy: false,
locale: 'en', locale: 'en',
@@ -36,7 +45,9 @@ function createWrapper() {
sideToolbar: { sideToolbar: {
queueProgressOverlay: { queueProgressOverlay: {
viewJobHistory: 'View job history', viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue' expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
} }
} }
} }
@@ -45,12 +56,17 @@ function createWrapper() {
return mount(TopMenuSection, { return mount(TopMenuSection, {
global: { global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n], plugins: [pinia, i18n],
stubs: { stubs: {
SubgraphBreadcrumb: true, SubgraphBreadcrumb: true,
QueueProgressOverlay: true, QueueProgressOverlay: true,
CurrentUserButton: true, CurrentUserButton: true,
LoginButton: true LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
}, },
directives: { directives: {
tooltip: () => {} tooltip: () => {}
@@ -59,6 +75,19 @@ 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', () => { describe('TopMenuSection', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks() vi.resetAllMocks()
@@ -100,4 +129,104 @@ 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('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
await nextTick()
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
true
)
expect(
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
).toBe(false)
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
)
})
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
})
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
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,31 @@
<Button <Button
v-tooltip.bottom="queueHistoryTooltipConfig" v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive" type="destructive"
size="icon" size="md"
:aria-pressed="isQueueOverlayExpanded" :aria-pressed="
:aria-label=" isQueuePanelV2Enabled
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') ? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
" "
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay" @click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
> >
<i class="icon-[lucide--history] size-4" /> <span class="text-sm font-normal tabular-nums">
<span {{ activeJobsLabel }}
v-if="queuedCount > 0" </span>
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" <span class="sr-only">
> {{
{{ queuedCount }} isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span> </span>
</Button> </Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton <CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar" v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0" class="shrink-0"
@@ -77,6 +87,7 @@
</div> </div>
</div> </div>
<QueueProgressOverlay <QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded" v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered" :menu-hovered="isTopMenuHovered"
/> />
@@ -86,6 +97,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -103,8 +116,10 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore' import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore' import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil' import { isElectron } from '@/utils/envUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment' import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -117,27 +132,56 @@ const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState() const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser() const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron() const isDesktop = isElectron()
const { t } = useI18n() const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling() const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore() const commandStore = useCommandStore()
const queueStore = useQueueStore() const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const queueUIStore = useQueueUIStore() const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore) const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore() const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore) const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } = const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment() useConflictAcknowledgment()
const isTopMenuHovered = ref(false) const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length) const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const isIntegratedTabBar = computed( const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated' () => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
) )
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const queueHistoryTooltipConfig = computed(() => const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory')) buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
) )
const customNodesManagerTooltipConfig = computed(() => const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager')) 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 // Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => { const shouldShowRedDot = computed((): boolean => {
@@ -161,9 +205,26 @@ onMounted(() => {
}) })
const toggleQueueOverlay = () => { const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay') 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 () => { const openCustomNodeManager = async () => {
try { try {
await managerState.openManager({ await managerState.openManager({

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
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,30 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast' import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
const { label, severity = 'default' } = defineProps<{ const {
label: string label,
severity?: Severity severity = 'default',
variant
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
}>() }>()
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> </script>
<template> <template>
<span :class="badgeClasses(severity)">{{ label }}</span> <span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
<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

@@ -0,0 +1,26 @@
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,7 +4,12 @@
v-for="item in dialogStore.dialogStack" v-for="item in dialogStore.dialogStack"
:key="item.key" :key="item.key"
v-model:visible="item.visible" v-model:visible="item.visible"
class="global-dialog" :class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
v-bind="item.dialogComponentProps" v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt" :pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key" :aria-labelledby="item.key"
@@ -38,7 +43,15 @@
<script setup lang="ts"> <script setup lang="ts">
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { computed } from 'vue'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
</script> </script>
@@ -55,4 +68,27 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)]; @apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0; @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> </style>

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
<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

@@ -0,0 +1,163 @@
<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

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

View File

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

View File

@@ -0,0 +1,113 @@
<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

@@ -0,0 +1,89 @@
<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

@@ -0,0 +1,104 @@
<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

@@ -0,0 +1,78 @@
<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

@@ -200,7 +200,13 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (item.state === 'running' || item.state === 'initialization') { if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution // Running/initializing jobs: interrupt execution
await api.interrupt(promptId) // Cloud backend uses deleteItem, local uses interrupt
if (isCloud) {
await api.deleteItem('queue', promptId)
} else {
await api.interrupt(promptId)
}
executionStore.clearInitializationByPromptId(promptId)
await queueStore.update() await queueStore.update()
} else if (item.state === 'pending') { } else if (item.state === 'pending') {
// Pending jobs: remove from queue // Pending jobs: remove from queue
@@ -268,7 +274,15 @@ const inspectJobAsset = wrapWithErrorHandlingAsync(
) )
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
// Capture pending promptIds before clearing
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks') await commandStore.execute('Comfy.ClearPendingTasks')
// Clear initialization state for removed prompts
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}) })
const interruptAll = wrapWithErrorHandlingAsync(async () => { const interruptAll = wrapWithErrorHandlingAsync(async () => {
@@ -284,10 +298,14 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
// on cloud to ensure we cancel the workflow the user clicked. // on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) { if (isCloud) {
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id))) await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
return return
} }
await Promise.all(promptIds.map((id) => api.interrupt(id))) await Promise.all(promptIds.map((id) => api.interrupt(id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
}) })
const showClearHistoryDialog = () => { const showClearHistoryDialog = () => {

View File

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

View File

@@ -8,7 +8,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue' import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgetsAndNodes } from '../shared' import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared' import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue' import SectionWidgets from './SectionWidgets.vue'
@@ -24,18 +24,7 @@ const nodes = computed((): LGraphNode[] => {
const rightSidePanelStore = useRightSidePanelStore() const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore) const { searchQuery } = storeToRefs(rightSidePanelStore)
const widgetsSectionDataList = computed((): NodeWidgetsListList => { const { widgetsSectionDataList } = computedSectionDataList(nodes)
return nodes.value.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return {
widgets: shownWidgets,
node
}
})
})
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>( const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
widgetsSectionDataList.value widgetsSectionDataList.value

View File

@@ -7,7 +7,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue' import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgetsAndNodes } from '../shared' import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared' import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue' import SectionWidgets from './SectionWidgets.vue'
@@ -21,15 +21,26 @@ const { t } = useI18n()
const rightSidePanelStore = useRightSidePanelStore() const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore) const { searchQuery } = storeToRefs(rightSidePanelStore)
const widgetsSectionDataList = computed((): NodeWidgetsListList => { const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
return nodes.map((node) => { () => nodes
const { widgets = [] } = node )
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node } const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
}) if (includesAdvanced.value) {
return []
}
return nodes
.map((node) => {
const { widgets = [] } = node
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }
})
.filter(({ widgets }) => widgets.length > 0)
}) })
const isMultipleNodesSelected = computed( const isMultipleNodesSelected = computed(
@@ -56,6 +67,12 @@ const label = computed(() => {
: t('rightSidePanel.inputsNone') : t('rightSidePanel.inputsNone')
: undefined // SectionWidgets display node titles by default : undefined // SectionWidgets display node titles by default
}) })
const advancedLabel = computed(() => {
return !mustShowNodeTitle && !isMultipleNodesSelected.value
? t('rightSidePanel.advancedInputs')
: undefined // SectionWidgets display node titles by default
})
</script> </script>
<template> <template>
@@ -93,4 +110,16 @@ const label = computed(() => {
class="border-b border-interface-stroke" class="border-b border-interface-stroke"
/> />
</TransitionGroup> </TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
<SectionWidgets
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
:key="`advanced-${node.id}`"
:collapse="true"
:node
:label="advancedLabel"
:widgets
:show-locate-button="isMultipleNodesSelected"
class="border-b border-interface-stroke"
/>
</template>
</template> </template>

View File

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import InputNumber from 'primevue/inputnumber' import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select' import Select from 'primevue/select'
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
@@ -23,7 +23,11 @@ const settingStore = useSettingStore()
const dialogService = useDialogService() const dialogService = useDialogService()
// NODES settings // NODES settings
const showAdvancedParameters = ref(false) // Placeholder for future implementation const showAdvancedParameters = computed({
get: () => settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets'),
set: (value) =>
settingStore.set('Comfy.Node.AlwaysShowAdvancedWidgets', value)
})
const showToolbox = computed({ const showToolbox = computed({
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'), get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),

View File

@@ -6,6 +6,7 @@ import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil' import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
export const GetNodeParentGroupKey: InjectionKey< export const GetNodeParentGroupKey: InjectionKey<
(node: LGraphNode) => LGraphGroup | null (node: LGraphNode) => LGraphGroup | null
@@ -203,3 +204,33 @@ function repeatItems<T>(items: T[]): T[] {
} }
return result return result
} }
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
const settingStore = useSettingStore()
const includesAdvanced = computed(() =>
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return toValue(nodes).map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(
w.options?.canvasOnly ||
w.options?.hidden ||
(w.options?.advanced && !includesAdvanced.value)
)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
return {
widgetsSectionDataList,
includesAdvanced
}
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
:label="$t('menu.help')" :label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')" :tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''" :icon-badge="shouldShowRedDot ? '' : ''"
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
:is-small="isSmall" :is-small="isSmall"
@click="toggleHelpCenter" @click="toggleHelpCenter"
/> />
@@ -21,24 +22,3 @@ defineProps<{
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter() const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
</script> </script>
<style scoped>
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;
min-width: 8px;
height: 8px;
padding: 0;
border-radius: 9999px;
font-size: 0;
margin-top: 4px;
margin-right: 4px;
border: none;
outline: none;
box-shadow: none;
}
:deep(.p-badge.p-badge-dot) {
width: 8px !important;
}
</style>

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config' import PrimeVue from 'primevue/config'
import OverlayBadge from 'primevue/overlaybadge'
import Tooltip from 'primevue/tooltip' import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@@ -33,8 +32,7 @@ describe('SidebarIcon', () => {
return mount(SidebarIcon, { return mount(SidebarIcon, {
global: { global: {
plugins: [PrimeVue, i18n], plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }, directives: { tooltip: Tooltip }
components: { OverlayBadge }
}, },
props: { ...exampleProps, ...props }, props: { ...exampleProps, ...props },
...options ...options
@@ -54,9 +52,9 @@ describe('SidebarIcon', () => {
it('creates badge when iconBadge prop is set', () => { it('creates badge when iconBadge prop is set', () => {
const badge = '2' const badge = '2'
const wrapper = mountSidebarIcon({ iconBadge: badge }) const wrapper = mountSidebarIcon({ iconBadge: badge })
const badgeEl = wrapper.findComponent(OverlayBadge) const badgeEl = wrapper.find('.sidebar-icon-badge')
expect(badgeEl.exists()).toBe(true) expect(badgeEl.exists()).toBe(true)
expect(badgeEl.find('.p-badge').text()).toEqual(badge) expect(badgeEl.text()).toEqual(badge)
}) })
it('shows tooltip on hover', async () => { it('shows tooltip on hover', async () => {

View File

@@ -17,22 +17,28 @@
> >
<div class="side-bar-button-content"> <div class="side-bar-button-content">
<slot name="icon"> <slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue"> <div class="sidebar-icon-wrapper relative">
<i <i
v-if="typeof icon === 'string'" v-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'" :class="icon + ' side-bar-button-icon'"
/> />
<component :is="icon" v-else class="side-bar-button-icon" /> <component
</OverlayBadge> :is="icon"
<i v-else-if="typeof icon === 'object'"
v-else-if="typeof icon === 'string'" class="side-bar-button-icon"
:class="icon + ' side-bar-button-icon'" />
/> <span
<component v-if="shouldShowBadge"
:is="icon" :class="
v-else-if="typeof icon === 'object'" cn(
class="side-bar-button-icon" 'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground',
/> badgeClass || '-top-1 -right-1'
)
"
>
{{ overlayValue }}
</span>
</div>
</slot> </slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{ <span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label) t(label)
@@ -42,7 +48,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue' import { computed } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -57,6 +62,7 @@ const {
tooltip = '', tooltip = '',
tooltipSuffix = '', tooltipSuffix = '',
iconBadge = '', iconBadge = '',
badgeClass = '',
label = '', label = '',
isSmall = false isSmall = false
} = defineProps<{ } = defineProps<{
@@ -65,6 +71,7 @@ const {
tooltip?: string tooltip?: string
tooltipSuffix?: string tooltipSuffix?: string
iconBadge?: string | (() => string | null) iconBadge?: string | (() => string | null)
badgeClass?: string
label?: string label?: string
isSmall?: boolean isSmall?: boolean
}>() }>()

View File

@@ -0,0 +1,113 @@
<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 class="flex h-full flex-col">
<div <div
v-if="activeJobItems.length" v-if="activeJobItems.length"
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2" class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
> >
<AssetsListItem <AssetsListItem
v-for="job in activeJobItems" v-for="job in activeJobItems"
@@ -44,9 +44,15 @@
:class="cn('px-2', activeJobItems.length && 'mt-2')" :class="cn('px-2', activeJobItems.length && 'mt-2')"
> >
<div <div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter" class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
> >
{{ t('sideToolbar.generatedAssetsHeader') }} {{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div> </div>
</div> </div>
@@ -108,7 +114,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil' import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import type { JobState } from '@/types/queue' import { isActiveJobState } from '@/utils/queueUtil'
import { import {
formatDuration, formatDuration,
formatSize, formatSize,
@@ -118,9 +124,14 @@ import {
import { iconForJobState } from '@/utils/queueDisplay' import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const { assets, isSelected } = defineProps<{ const {
assets,
isSelected,
assetType = 'output'
} = defineProps<{
assets: AssetItem[] assets: AssetItem[]
isSelected: (assetId: string) => boolean isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -161,12 +172,6 @@ const listGridStyle = {
gap: '0.5rem' gap: '0.5rem'
} }
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string { function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name) return truncateFilename(asset.name)
} }

View File

@@ -100,34 +100,24 @@
v-if="isListView" v-if="isListView"
:assets="displayAssets" :assets="displayAssets"
:is-selected="isSelected" :is-selected="isSelected"
:asset-type="activeTab"
@select-asset="handleAssetSelect" @select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu" @context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd" @approach-end="handleApproachEnd"
/> />
<VirtualGrid <AssetsSidebarGridView
v-else v-else
:items="mediaAssetsWithKey" :assets="displayAssets"
:grid-style="{ :is-selected="isSelected"
display: 'grid', :asset-type="activeTab"
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', :show-output-count="shouldShowOutputCount"
padding: '0 0.5rem', :get-output-count="getOutputCount"
gap: '0.5rem' @select-asset="handleAssetSelect"
}" @context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd" @approach-end="handleApproachEnd"
> @zoom="handleZoomClick"
<template #item="{ item }"> @output-count-click="enterFolderView"
<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> </div>
</template> </template>
<template #footer> <template #footer>
@@ -212,6 +202,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core' import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider' import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner' import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
@@ -219,15 +210,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.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 AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue' import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue' import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue' import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue' import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.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 MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue' import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers' import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
@@ -243,6 +233,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache' import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore' import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil' import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
@@ -256,6 +247,8 @@ interface JobOutputItem {
const { t, n } = useI18n() const { t, n } = useI18n()
const commandStore = useCommandStore() const commandStore = useCommandStore()
const queueStore = useQueueStore() const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output') const activeTab = ref<'input' | 'output'>('output')
@@ -301,9 +294,6 @@ const formattedExecutionTime = computed(() => {
}) })
const queuedCount = computed(() => queueStore.pendingTasks.length) const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(() => { const activeJobsLabel = computed(() => {
const count = activeJobsCount.value const count = activeJobsCount.value
return t( return t(
@@ -404,14 +394,14 @@ const showLoadingState = computed(
() => () =>
loading.value && loading.value &&
displayAssets.value.length === 0 && displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0) activeJobsCount.value === 0
) )
const showEmptyState = computed( const showEmptyState = computed(
() => () =>
!loading.value && !loading.value &&
displayAssets.value.length === 0 && displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0) activeJobsCount.value === 0
) )
watch(displayAssets, (newAssets) => { watch(displayAssets, (newAssets) => {
@@ -453,14 +443,6 @@ const galleryItems = computed(() => {
}) })
}) })
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => { const refreshAssets = async () => {
await currentAssets.value.fetchMediaList() await currentAssets.value.fetchMediaList()
if (error.value) { if (error.value) {
@@ -510,7 +492,13 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
} }
const handleClearQueue = async () => { 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') await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
} }
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => { const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {

View File

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

View File

@@ -0,0 +1,337 @@
<!-- 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

@@ -0,0 +1,166 @@
<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

@@ -0,0 +1,19 @@
# 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,7 +26,8 @@ export const buttonVariants = cva({
md: 'h-8 rounded-lg p-2 text-xs', md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm', lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-8', icon: 'size-8',
'icon-sm': 'size-5 p-0' 'icon-sm': 'size-5 p-0',
unset: ''
} }
}, },

View File

@@ -0,0 +1,261 @@
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

@@ -0,0 +1,16 @@
<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>

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