Compare commits

...

12 Commits

Author SHA1 Message Date
Simula_r
1e2e517cc1 refactor: PR feedback 2026-02-09 21:23:25 -08:00
AustinMroz
a6620a4ddc Fix edge cases in subgraph removal logic (#8758)
#8187 made removal of subgraphs cleanup the subgraph definition for the
removed graph and call onRemove handlers. However, it missed some edge
cases and broke subgraph conversion of selections containing subgraphs
which this PR tries to address.
- Deeply nested subgraphs are now also cleaned
- Adding a subgraphNode to the graph also ensures nested subgraphs are
added to subgraph definitions

Reminder: under this change, nodes can continue to exist after their
onRemoved handler has been called
- It may be better to instead perform the "garbage collection" of
subgraphs outside of the graph removal step to better handle edge cases
like subgraph conversion where a subgraph may continue to persist after
a parent subgraphNode has been removed from a graph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8758-Fix-edge-cases-in-subgraph-removal-logic-3026d73d36508177b34ffdd2e0a114fe)
by [Unito](https://www.unito.io)
2026-02-09 13:55:23 -08:00
Benjamin Lu
9209badd37 feat: add KSampler live previews to assets sidebar jobs (#8723)
## Summary
Show live KSampler previews on active job cards/list items in the Assets
sidebar, while preserving existing fallback behavior.

## Changes
- **What**:
- Added a prompt-scoped job preview store (`jobPreviewStore`) gated by
`Comfy.Execution.PreviewMethod`.
- Wired `b_preview_with_metadata` handling to map previews by
`promptId`.
- Extended queue job view model with `livePreviewUrl` and consumed it in
both sidebar list and grid active job UIs.
  - Cleared prompt previews on execution reset.
- Added ref-counted shared blob URL lifecycle utility (`objectUrlUtil`)
and updated preview stores to retain/release shared URLs so each preview
event creates one object URL.
- Added/updated unit coverage in `useJobList.test.ts` for preview
enable/disable mapping.

## Review Focus
- Object URL lifecycle correctness across node previews and job previews
(retain/release behavior).
- Preview gating behavior when `Comfy.Execution.PreviewMethod` is
`none`.
- Active job UI fallback behavior (`livePreviewUrl` -> `iconImageUrl`).

## Screenshots (if applicable)
<img width="808" height="614" alt="image"
src="https://github.com/user-attachments/assets/37c66eb2-8c28-4eb4-bb86-5679cb77d740"
/>
<img width="775" height="345" alt="image"
src="https://github.com/user-attachments/assets/aa420642-b0d4-4ae6-b94a-e7934b5df9d6"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8723-feat-add-KSampler-live-previews-to-assets-sidebar-jobs-3006d73d365081aeb81dd8279bf99f94)
by [Unito](https://www.unito.io)
2026-02-09 10:49:27 -08:00
Benjamin Lu
815be49112 fix: keep begin_checkout user_id reactive in subscription flows (#8726)
## Summary

Use reactive `userId` reads for `begin_checkout` telemetry so delayed
auth state updates are reflected at event time instead of using a stale
snapshot.

## Changes

- **What**: switched subscription checkout telemetry paths to
`storeToRefs(useFirebaseAuthStore())` and read `userId.value` when
dispatching `trackBeginCheckout`.
- **What**: added regression tests that mutate `userId` after setup /
after checkout starts and assert telemetry uses the updated ID.

## Review Focus

- Verify `PricingTable` and `performSubscriptionCheckout` still emit
exactly one `begin_checkout` event per action, with `checkout_type:
change` and `checkout_type: new` in their respective paths.
- Verify the new tests would fail with stale store destructuring
(manually validated during development).

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8726-fix-keep-begin_checkout-user_id-reactive-in-subscription-flows-3006d73d365081888c84c0335ab52e09)
by [Unito](https://www.unito.io)
2026-02-09 02:01:23 -08:00
Hunter
adbfb83767 feat: wire renewal_date from cloud billing status (#8754)
## Summary

Wire `renewal_date` from the cloud `/billing/status` response into the
workspace subscription UI so users can see when their subscription
renews.

## Problem

The workspace billing adapter hardcoded `renewalDate: null` because the
cloud billing status endpoint didn't return a renewal date. The
`SubscriptionPanelContentWorkspace` component already has UI for
displaying it — it just had no data.

Personal Workspace (existing `cloud-subscription-status`):
<img width="181" height="112" alt="Screenshot 2026-02-08 at 7 54 53 PM"
src="https://github.com/user-attachments/assets/a96ba2cd-10d0-442a-ae72-dbc663a9e52b"
/>

Current missing data from `/billing/status`:
<img width="240" height="124" alt="Screenshot 2026-02-08 at 7 55 38 PM"
src="https://github.com/user-attachments/assets/a3f51ce3-6663-43e1-97ed-d012a6a8a5ba"
/>

## Solution

- Add `renewal_date?: string` to `BillingStatusResponse` interface
- Use `status.renewal_date ?? null` instead of hardcoded `null` in
`useWorkspaceBilling`

### Related
- Cloud PR: Comfy-Org/cloud#2370 (adds `renewal_date` to the endpoint)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8754-feat-wire-renewal_date-from-cloud-billing-status-3026d73d365081c7ae51d79ef0633a1d)
by [Unito](https://www.unito.io)
2026-02-08 23:49:20 -08:00
Terry Jia
3238ad3d32 fix: re-mount DOM widget elements after leaving Linear mode (#8753)
## Summary

LinearView renders its own WidgetDOM instances which steal the
widget.element via replaceChildren. When LinearView unmounts
(v-if="linearMode") the element is removed from the DOM entirely.
The canvas-side WidgetDOM stays mounted but its container is now empty,
so DOM widgets (e.g. Three.js scenes) disappear.

Watch canvasStore.linearMode and reclaim the element when switching back
from Linear to Canvas mode.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/78cea2bc-c4b3-4b21-bdb3-a521bb0d3062


after


https://github.com/user-attachments/assets/8f92c44d-9514-4001-bbdb-bc4c80468ed7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8753-fix-re-mount-DOM-widget-elements-after-leaving-Linear-mode-3026d73d3650810b8968eff13dc84e9a)
by [Unito](https://www.unito.io)
2026-02-08 22:51:57 -05:00
Comfy Org PR Bot
be515d6fcc 1.39.10 (#8752)
Patch version increment to 1.39.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8752-1-39-10-3026d73d3650816ebe1edb895feb37a1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-08 17:30:05 -08:00
Christian Byrne
b583c92c64 fix: only show Failed Tests section when there are actual failures (#8575)
## Description

The Playwright test comment was showing a " Failed Tests" section
header even when there were only flaky tests (no actual failures). This
was confusing because the red X suggested failure when tests actually
passed.

**Before:** Shows " Failed Tests" section for flaky-only runs
**After:** Only shows " Failed Tests" section when there are actual
failures; flaky tests are treated as passing

## Related Issue

Fixes the misleading comment behavior seen in PR #8573



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8575-fix-only-show-Failed-Tests-section-when-there-are-actual-failures-2fc6d73d36508167889cc252e4e06f2e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-08 13:14:38 -08:00
Christian Byrne
c0a209226d fix: handle RIFF padding for odd-sized WEBP chunks (#8527)
## Summary

Fix WEBP workflow loading failures for files with odd-sized chunks
before the EXIF chunk.

## Problem

WEBP files use the RIFF container format which [requires odd-sized
chunks to be padded with a single
byte](https://developers.google.com/speed/webp/docs/riff_container#riff_file_format):

> If Chunk Size is odd, a single padding byte -- which MUST be 0 to
conform with RIFF -- is added.

The `getWebpMetadata` function was not accounting for this padding,
causing it to miss the EXIF chunk in files with odd-sized preceding
chunks. This resulted in "Unable to find workflow in [filename].webp"
errors for valid WEBP files.

## Solution

Add the padding byte to the offset calculation:
```typescript
offset += 8 + chunk_length + (chunk_length % 2)
```

## Testing

- Tested with the sample image provided in the issue which previously
failed to load

Fixes #8076

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8527-fix-handle-RIFF-padding-for-odd-sized-WEBP-chunks-2fa6d73d3650815fb849fb6a4e767162)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-08 12:20:31 -08:00
Christian Byrne
c91d811d00 feat(cloud): add asset widget support for PrimitiveNode model selection (#8598)
Add cloud asset widget creation in `_createWidget()` using
`isAssetBrowserEligible()`
- Extract shared `createAssetWidget` factory to
`src/platform/assets/utils/`
- Refactor `useComboWidget.ts` to use the shared factory
- Add `_finalizeWidget()` helper to DRY up widget sizing/callback setup
- Pass target node's `comfyClass` and input name to Asset Browser for
correct model filtering
- Check `Comfy.Assets.UseAssetAPI` setting (matches `useComboWidget.ts`
behavior)
- Sync existing target widget value to asset widget
- Add toast notifications for asset validation errors
- Add i18n translations for invalidAsset and invalidFilename errors

Supersedes #8461 (clean rebase, no merge commits)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8598-feat-cloud-add-asset-widget-support-for-PrimitiveNode-model-selection-2fd6d73d365081a8afa7c2e91762f11c)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced asset widget integration for cloud-based model selection,
enabling users to browse and select assets through an improved
interface.
* Added comprehensive asset validation with enhanced error messages for
invalid assets and filenames.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: guill <jacob.e.segal@gmail.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: sno <snomiao@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-08 12:18:13 -08:00
Alexander Brown
e625b0351c feat: migrate from @iconify/tailwind to @iconify/tailwind4 (#8724)
## Summary

Migrate from `@iconify/tailwind` (Tailwind 3 JS plugin) to
`@iconify/tailwind4` (native Tailwind 4 CSS plugin), moving all config
into CSS directives.

## Changes

- **What**: Replace `addDynamicIconSelectors()` JS plugin with `@plugin
"@iconify/tailwind4"` CSS directive. Move `boxShadow` theme extension
into `@theme` block. Delete both `tailwind.config.ts` files and the
runtime `iconCollection.ts` module.
- **Dependencies**: `@iconify/tailwind` removed, `@iconify/tailwind4`
added

## Review Focus

- `from-folder` path resolution in monorepo context (paths relative to
project root)
- SVG auto-cleanup behavior of `from-folder` vs the previous manual
`iconCollection.ts` loader
- Removal of `@config` directive and both tailwind config files — all
config now in CSS

## Files

| File | Change |
|------|--------|
| `pnpm-workspace.yaml` | Swap catalog entry |
| `packages/design-system/package.json` | Swap dep, remove
`tailwind-config` export |
| `packages/design-system/src/css/style.css` | Add `@plugin`,
`--shadow-interface` theme token, remove `@config` |
| `packages/design-system/tailwind.config.ts` | Deleted |
| `packages/design-system/src/iconCollection.ts` | Deleted |
| `tailwind.config.ts` | Deleted |
| `tsconfig.json`, `components.json` | Remove stale references |
| `knip.config.ts` | Ignore `@iconify-json/lucide` (CSS-consumed, not
JS-imported) |
| Docs | Updated `CONTRIBUTING.md` and `icons/README.md` |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8724-feat-migrate-from-iconify-tailwind-to-iconify-tailwind4-3006d73d36508144a9b3e7ae73448f98)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-07 22:35:34 -08:00
Benjamin Lu
a56f2d3883 fix: stabilize flaky workflows save-as browser assertions (#8735)
## Summary

Stabilize flaky workflows sidebar browser tests by waiting for eventual
UI state after `Save As`/overwrite operations.

## Changes

- **What**: Replace immediate assertions with retrying
`expect.poll(...)` in `browser_tests/tests/sidebar/workflows.spec.ts`
for:
  - `Can save workflow as`
  - `Can overwrite other workflows with save as`

## Review Focus

Verify the polling assertions are scoped to the intended eventual UI
state and do not hide real regressions.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8735-fix-stabilize-flaky-workflows-save-as-browser-assertions-3016d73d3650814abad1d767c0910ef6)
by [Unito](https://www.unito.io)
2026-02-07 22:35:14 -08:00
68 changed files with 3594 additions and 320 deletions

View File

@@ -59,7 +59,7 @@
"function-no-unknown": [
true,
{
"ignoreFunctions": ["theme", "v-bind"]
"ignoreFunctions": ["theme", "v-bind", "from-folder", "from-json"]
}
]
},

View File

@@ -201,7 +201,7 @@ The project supports three types of icons, all with automatic imports (no manual
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i class="icon-[lucide--settings]" />`, `<i class="icon-[mdi--folder]" />`
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation.
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Tailwind CSS icon classes (`icon-[comfy--template]`) are provided by `@iconify/tailwind4`, configured in `packages/design-system/src/css/style.css`. Custom icons are stored in `packages/design-system/src/icons/` and loaded via `from-folder` at build time.
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).

View File

@@ -123,17 +123,14 @@ test.describe('Workflows sidebar', () => {
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'workflow3.json'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'workflow3.json',
'workflow4.json'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
})
test('Exported workflow does not contain localized slot names', async ({
@@ -220,24 +217,22 @@ test.describe('Workflows sidebar', () => {
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await comfyPage.nextFrame()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'workflow2.json'
])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow2.json'
)
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow1.json', 'workflow2.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow2.json')
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog.click('overwrite')
// The old workflow1.json should be deleted and the new one should be saved.
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow2.json',
'workflow1.json'
])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow1.json'
)
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow2.json', 'workflow1.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow1.json')
})
test('Does not report warning when switching between opened workflows', async ({

View File

@@ -3,7 +3,6 @@
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/assets/css/style.css",
"baseColor": "stone",
"cssVariables": true,

View File

@@ -20,10 +20,6 @@ const config: KnipConfig = {
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/design-system': {
entry: ['src/**/*.ts'],
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
}
@@ -31,6 +27,7 @@ const config: KnipConfig = {
ignoreBinaries: ['python3', 'gh'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.9",
"version": "1.39.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -4,7 +4,6 @@
"description": "Shared design system for ComfyUI Frontend",
"type": "module",
"exports": {
"./tailwind-config": "./tailwind.config.ts",
"./css/*": "./src/css/*"
},
"scripts": {
@@ -12,7 +11,7 @@
},
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind": "catalog:"
"@iconify/tailwind4": "catalog:"
},
"devDependencies": {
"tailwindcss": "catalog:",

View File

@@ -7,11 +7,16 @@
@plugin 'tailwindcss-primeui';
@config '../../tailwind.config.ts';
@plugin "@iconify/tailwind4" {
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
@custom-variant touch (@media (hover: none));
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);

View File

@@ -1,100 +0,0 @@
import { existsSync, readFileSync, readdirSync } from 'fs'
import { join } from 'path'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
const fileName = fileURLToPath(import.meta.url)
const dirName = dirname(fileName)
const customIconsPath = join(dirName, 'icons')
// Iconify collection structure
interface IconifyIcon {
body: string
width?: number
height?: number
}
interface IconifyCollection {
prefix: string
icons: Record<string, IconifyIcon>
width?: number
height?: number
}
// Create an Iconify collection for custom icons
export const iconCollection: IconifyCollection = {
prefix: 'comfy',
icons: {},
width: 16,
height: 16
}
/**
* Validates that an SVG file contains valid SVG content
*/
function validateSvgContent(content: string, filename: string): void {
if (!content.trim()) {
throw new Error(`Empty SVG file: ${filename}`)
}
if (!content.includes('<svg')) {
throw new Error(`Invalid SVG file (missing <svg> tag): ${filename}`)
}
// Basic XML structure validation
const openTags = (content.match(/<svg[^>]*>/g) || []).length
const closeTags = (content.match(/<\/svg>/g) || []).length
if (openTags !== closeTags) {
throw new Error(`Malformed SVG file (mismatched svg tags): ${filename}`)
}
}
/**
* Loads custom SVG icons from the icons directory
*/
function loadCustomIcons(): void {
if (!existsSync(customIconsPath)) {
console.warn(`Custom icons directory not found: ${customIconsPath}`)
return
}
try {
const files = readdirSync(customIconsPath)
const svgFiles = files.filter((file) => file.endsWith('.svg'))
if (svgFiles.length === 0) {
console.warn('No SVG files found in custom icons directory')
return
}
svgFiles.forEach((file) => {
const name = file.replace('.svg', '')
const filePath = join(customIconsPath, file)
try {
const content = readFileSync(filePath, 'utf-8')
validateSvgContent(content, file)
iconCollection.icons[name] = {
body: content
}
} catch (error) {
console.error(
`Failed to load custom icon ${file}:`,
error instanceof Error ? error.message : error
)
// Continue loading other icons instead of failing the entire build
}
})
} catch (error) {
console.error(
'Failed to read custom icons directory:',
error instanceof Error ? error.message : error
)
// Don't throw here - allow build to continue without custom icons
}
}
// Load icons when this module is imported
loadCustomIcons()

View File

@@ -251,26 +251,25 @@ Icons are automatically imported using `unplugin-icons` - no manual imports need
The icon system has two layers:
1. **Build-time Processing** (`packages/design-system/src/iconCollection.ts`):
- Scans `packages/design-system/src/icons/` for SVG files
- Validates SVG content and structure
- Creates Iconify collection for Tailwind CSS
- Provides error handling for malformed files
1. **Tailwind CSS Plugin** (`@iconify/tailwind4`):
- Configured via `@plugin` directive in `packages/design-system/src/css/style.css`
- Uses `from-folder(comfy, ...)` to load SVGs from `packages/design-system/src/icons/`
- Auto-cleans and optimizes SVGs at build time
2. **Vite Runtime** (`vite.config.mts`):
- Enables direct SVG import as Vue components
- Supports dynamic icon loading
```typescript
// Build script creates Iconify collection
export const iconCollection: IconifyCollection = {
prefix: 'comfy',
icons: {
workflow: { body: '<svg>...</svg>' },
node: { body: '<svg>...</svg>' }
}
```css
/* CSS configuration for Tailwind icon classes */
@plugin "@iconify/tailwind4" {
prefix: 'icon';
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
```
```typescript
// Vite configuration for component-based usage
Icons({
compiler: 'vue3',

View File

@@ -1,24 +0,0 @@
import lucide from '@iconify-json/lucide/icons.json' with { type: 'json' }
import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './src/iconCollection'
export default {
theme: {
extend: {
boxShadow: {
interface: 'var(--interface-panel-box-shadow)'
}
}
},
plugins: [
addDynamicIconSelectors({
iconSets: {
comfy: iconCollection,
lucide
},
scale: 1.2,
prefix: 'icon'
})
]
}

107
pnpm-lock.yaml generated
View File

@@ -21,9 +21,9 @@ catalogs:
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind':
specifier: ^1.1.3
version: 1.2.0
'@iconify/tailwind4':
specifier: ^1.2.0
version: 1.2.1
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0
@@ -805,9 +805,9 @@ importers:
'@iconify-json/lucide':
specifier: 'catalog:'
version: 1.2.79
'@iconify/tailwind':
'@iconify/tailwind4':
specifier: 'catalog:'
version: 1.2.0
version: 1.2.1(tailwindcss@4.1.12)
devDependencies:
tailwindcss:
specifier: 'catalog:'
@@ -1528,6 +1528,9 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
'@cyberalien/svg-utils@1.1.1':
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
'@dual-bundle/import-meta-resolve@4.2.1':
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
@@ -2149,8 +2152,13 @@ packages:
'@iconify/json@2.2.380':
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
'@iconify/tailwind@1.2.0':
resolution: {integrity: sha512-KgpIHWOTcRYw1XcoUqyNSrmYyfLLqZYu3AmP8zdfLk0F5TqRO8YerhlvlQmGfn7rJXgPeZN569xPAJnJ53zZxA==}
'@iconify/tailwind4@1.2.1':
resolution: {integrity: sha512-Hd7k8y7uzT3hk8ltw0jGku0r0wA8sc3d2iMvVTYv/9tMxBb+frZtWZGD9hDMU3EYuE+lMn58wi2lS8R2ZbwFcQ==}
peerDependencies:
tailwindcss: '>= 4.0.0'
'@iconify/tools@5.0.3':
resolution: {integrity: sha512-W5nbH5fNv20TvU49Al19Foos/ViAnmppbCNV9ieGl6/dRMDRzxeFol6peXX/NAgaOytQwZZxTTJRq/Kxd4eWsA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -4664,6 +4672,10 @@ packages:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
commander@11.1.0:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
@@ -4769,6 +4781,13 @@ packages:
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-tree@2.2.1:
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@@ -4785,6 +4804,10 @@ packages:
engines: {node: '>=4'}
hasBin: true
csso@5.0.5:
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
cssstyle@5.3.5:
resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==}
engines: {node: '>=20'}
@@ -6490,6 +6513,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -6683,6 +6709,10 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
modern-tar@0.7.3:
resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
engines: {node: '>=18.0.0'}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -7441,6 +7471,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
sax@1.4.4:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -7704,6 +7738,11 @@ packages:
svg-tags@1.0.0:
resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
svgo@4.0.0:
resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==}
engines: {node: '>=16'}
hasBin: true
swr@2.3.6:
resolution: {integrity: sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==}
peerDependencies:
@@ -9435,6 +9474,10 @@ snapshots:
dependencies:
postcss-selector-parser: 7.1.1
'@cyberalien/svg-utils@1.1.1':
dependencies:
'@iconify/types': 2.0.0
'@dual-bundle/import-meta-resolve@4.2.1': {}
'@emnapi/core@1.7.1':
@@ -10023,9 +10066,22 @@ snapshots:
'@iconify/types': 2.0.0
pathe: 1.1.2
'@iconify/tailwind@1.2.0':
'@iconify/tailwind4@1.2.1(tailwindcss@4.1.12)':
dependencies:
'@iconify/tools': 5.0.3
'@iconify/types': 2.0.0
'@iconify/utils': 3.1.0
tailwindcss: 4.1.12
'@iconify/tools@5.0.3':
dependencies:
'@cyberalien/svg-utils': 1.1.1
'@iconify/types': 2.0.0
'@iconify/utils': 3.1.0
fflate: 0.8.2
modern-tar: 0.7.3
pathe: 2.0.3
svgo: 4.0.0
'@iconify/types@2.0.0': {}
@@ -12834,6 +12890,8 @@ snapshots:
commander@10.0.1: {}
commander@11.1.0: {}
commander@13.1.0: {}
commander@14.0.2: {}
@@ -12941,6 +12999,19 @@ snapshots:
domutils: 2.8.0
nth-check: 2.1.1
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
css-what: 6.1.0
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
css-tree@2.2.1:
dependencies:
mdn-data: 2.0.28
source-map-js: 1.2.1
css-tree@3.1.0:
dependencies:
mdn-data: 2.12.2
@@ -12952,6 +13023,10 @@ snapshots:
cssesc@3.0.0: {}
csso@5.0.5:
dependencies:
css-tree: 2.2.1
cssstyle@5.3.5:
dependencies:
'@asamuzakjp/css-color': 4.1.1
@@ -14893,6 +14968,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.0.28: {}
mdn-data@2.12.2: {}
mdurl@2.0.0: {}
@@ -15193,6 +15270,8 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.1
modern-tar@0.7.3: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -16233,6 +16312,8 @@ snapshots:
is-regex: 1.2.1
optional: true
sax@1.4.4: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@@ -16570,6 +16651,16 @@ snapshots:
svg-tags@1.0.0: {}
svgo@4.0.0:
dependencies:
commander: 11.1.0
css-select: 5.2.2
css-tree: 3.1.0
css-what: 6.1.0
csso: 5.0.5
picocolors: 1.1.1
sax: 1.4.4
swr@2.3.6(react@19.2.3):
dependencies:
dequal: 2.0.3

View File

@@ -8,7 +8,7 @@ catalog:
'@eslint/js': ^9.39.1
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind': ^1.1.3
'@iconify/tailwind4': ^1.2.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
'@lobehub/i18n-cli': ^1.25.1
'@nx/eslint': 22.2.6

View File

@@ -283,34 +283,27 @@ else
done
unset IFS
# Determine overall status
# Determine overall status (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="Passed"
else
status_icon="🕵🏻"
status_text="No test results"
fi
# Generate concise completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Tests: $status_icon **$status_text**"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
# Build flaky indicator if any (small subtext, no warning icon)
flaky_note=""
if [ $total_flaky -gt 0 ]; then
flaky_note=" · $total_flaky flaky"
fi
# Generate compact single-line comment
comment="$COMMENT_MARKER
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
# Extract and display failed tests from all browsers
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then
comment="$comment
### ❌ Failed Tests"

View File

@@ -18,7 +18,7 @@
</template>
<script setup lang="ts">
import { useElementBounding, useEventListener } from '@vueuse/core'
import { useElementBounding, useEventListener, whenever } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
@@ -180,6 +180,8 @@ watch(
mountElementIfVisible()
}
)
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
</script>
<style scoped>

View File

@@ -0,0 +1,20 @@
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
/**
* Formats a cent value to display credits.
* Backend returns cents despite the *_micros naming convention.
*/
export function formatBalance(
maybeCents: number | undefined,
locale: string
): string {
const cents = maybeCents ?? 0
return formatCreditsFromCents({
cents,
locale,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
})
}

View File

@@ -47,7 +47,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
tier: status.subscription_tier ?? null,
duration: status.subscription_duration ?? null,
planSlug: status.plan_slug ?? null,
renewalDate: null, // Workspace billing uses cancel_at for end date
renewalDate: status.renewal_date ?? null,
endDate: status.cancel_at ?? null,
isCancelled: status.subscription_status === 'canceled',
hasFunds: status.has_funds

View File

@@ -134,6 +134,25 @@ vi.mock('@/stores/executionStore', () => ({
}
}))
let jobPreviewStoreMock: {
previewsByPromptId: Record<string, string>
isPreviewEnabled: boolean
}
const ensureJobPreviewStore = () => {
if (!jobPreviewStoreMock) {
jobPreviewStoreMock = reactive({
previewsByPromptId: {} as Record<string, string>,
isPreviewEnabled: true
})
}
return jobPreviewStoreMock
}
vi.mock('@/stores/jobPreviewStore', () => ({
useJobPreviewStore: () => {
return ensureJobPreviewStore()
}
}))
let workflowStoreMock: {
activeWorkflow: null | { activeState?: { id?: string } }
}
@@ -186,6 +205,10 @@ const resetStores = () => {
executionStore.activePromptId = null
executionStore.executingNode = null
const jobPreviewStore = ensureJobPreviewStore()
jobPreviewStore.previewsByPromptId = {}
jobPreviewStore.isPreviewEnabled = true
const workflowStore = ensureWorkflowStore()
workflowStore.activeWorkflow = null
@@ -437,6 +460,44 @@ describe('useJobList', () => {
expect(otherJob.computeHours).toBeCloseTo(1)
})
it('assigns preview urls for running jobs when previews enabled', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'live-preview',
queueIndex: 1,
mockState: 'running'
})
]
jobPreviewStoreMock.previewsByPromptId = {
'live-preview': 'blob:preview-url'
}
jobPreviewStoreMock.isPreviewEnabled = true
const { jobItems } = initComposable()
await flush()
expect(jobItems.value[0].iconImageUrl).toBe('blob:preview-url')
})
it('omits preview urls when previews are disabled', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'disabled-preview',
queueIndex: 1,
mockState: 'running'
})
]
jobPreviewStoreMock.previewsByPromptId = {
'disabled-preview': 'blob:preview-url'
}
jobPreviewStoreMock.isPreviewEnabled = false
const { jobItems } = initComposable()
await flush()
expect(jobItems.value[0].iconImageUrl).toBeUndefined()
})
it('derives current node name from execution store fallbacks', async () => {
const instance = initComposable()
await flush()

View File

@@ -7,6 +7,7 @@ import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
@@ -96,6 +97,7 @@ export function useJobList() {
const { t, locale } = useI18n()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
const workflowStore = useWorkflowStore()
const seenPendingIds = ref<Set<string>>(new Set())
@@ -256,6 +258,11 @@ export function useJobList() {
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
const showAddedHint = shouldShowAddedHint(task, state)
const promptKey = taskIdToKey(task.promptId)
const promptPreviewUrl =
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
? jobPreviewStore.previewsByPromptId[promptKey]
: undefined
const display = buildJobDisplay(task, state, {
t,
@@ -275,7 +282,7 @@ export function useJobList() {
meta: display.secondary,
state,
iconName: display.iconName,
iconImageUrl: display.iconImageUrl,
iconImageUrl: promptPreviewUrl ?? display.iconImageUrl,
showClear: display.showClear,
taskRef: task,
progressTotalPercent:

View File

@@ -12,6 +12,10 @@ import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -19,10 +23,10 @@ import {
addValueControlWidgets,
isValidWidgetType
} from '@/scripts/widgets'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
@@ -228,6 +232,24 @@ export class PrimitiveNode extends LGraphNode {
// Store current size as addWidget resizes the node
const [oldWidth, oldHeight] = this.size
let widget: IBaseWidget
// Cloud: Use asset widget for model-eligible inputs when asset API is enabled
if (isCloud && type === 'COMBO') {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
widgetName
)
if (isUsingAssetAPI && isEligible) {
widget = this._createAssetWidget(node, widgetName, inputData)
const theirWidget = node.widgets?.find((w) => w.name === widgetName)
if (theirWidget) widget.value = theirWidget.value
this._finalizeWidget(widget, oldWidth, oldHeight, recreating)
return
}
}
if (isValidWidgetType(type)) {
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
} else {
@@ -277,20 +299,50 @@ export class PrimitiveNode extends LGraphNode {
}
}
// When our value changes, update other widgets to reflect our changes
// e.g. so LoadImage shows correct image
this._finalizeWidget(widget, oldWidth, oldHeight, recreating)
}
private _createAssetWidget(
targetNode: LGraphNode,
targetInputName: string,
inputData: InputSpec
): IBaseWidget {
const defaultValue = inputData[1]?.default as string | undefined
return createAssetWidget({
node: this,
widgetName: 'value',
nodeTypeForBrowser: targetNode.comfyClass ?? '',
inputNameForBrowser: targetInputName,
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
}
})
}
private _finalizeWidget(
widget: IBaseWidget,
oldWidth: number,
oldHeight: number,
recreating: boolean
) {
widget.callback = useChainCallback(widget.callback, () => {
this.applyToGraph()
})
// Use the biggest dimensions in case the widgets caused the node to grow
this.setSize([
Math.max(this.size[0], oldWidth),
Math.max(this.size[1], oldHeight)
])
if (!recreating) {
// Grow our node more if required
const sz = this.computeSize()
if (this.size[0] < sz[0]) {
this.size[0] = sz[0]

View File

@@ -8,6 +8,7 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
import type { DragAndScaleState } from './DragAndScale'
import { LGraphCanvas } from './LGraphCanvas'
@@ -973,6 +974,13 @@ export class LGraph
this.canvasAction((c) => c.startGhostPlacement(node, opts.dragEvent))
}
if (node.isSubgraphNode?.()) {
forEachNode(node.subgraph, (innerNode) => {
if (innerNode.isSubgraphNode())
this.subgraphs.set(innerNode.subgraph.id, innerNode.subgraph)
})
}
// to chain actions
return node
}
@@ -1034,14 +1042,14 @@ export class LGraph
}
}
// Subgraph cleanup (use local const to avoid type narrowing affecting node.graph assignment)
const subgraphNode = node.isSubgraphNode() ? node : null
if (subgraphNode) {
for (const innerNode of subgraphNode.subgraph.nodes) {
if (node.isSubgraphNode()) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
subgraphNode.subgraph.onNodeRemoved?.(innerNode)
}
this.rootGraph.subgraphs.delete(subgraphNode.subgraph.id)
innerNode.graph?.onNodeRemoved?.(innerNode)
if (innerNode.isSubgraphNode())
this.rootGraph.subgraphs.delete(innerNode.subgraph.id)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
// callback

View File

@@ -150,7 +150,9 @@ export { BaseWidget } from './widgets/BaseWidget'
export { LegacyWidget } from './widgets/LegacyWidget'
export { isComboWidget, isAssetWidget } from './widgets/widgetMap'
export { isComboWidget } from './widgets/widgetMap'
/** @knipIgnoreUnusedButUsedByCustomNodes */
export { isAssetWidget } from './widgets/widgetMap'
// Additional test-specific exports
export { LGraphButton } from './LGraphButton'
export { MovingOutputLink } from './canvas/MovingOutputLink'

View File

@@ -141,7 +141,10 @@ export function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}. */
/**
* Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}.
* @knipIgnoreUnusedButUsedByCustomNodes
*/
export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "الصق الرابط هنا",
"importAnother": "استيراد آخر",
"imported": "مستوردة",
"invalidAsset": "عنصر غير صالح",
"invalidAssetDetail": "تعذر التحقق من صحة العنصر المحدد. يرجى المحاولة مرة أخرى.",
"invalidFilename": "اسم ملف غير صالح",
"invalidFilenameDetail": "تعذر تحديد اسم ملف العنصر. يرجى المحاولة مرة أخرى.",
"jobId": "معرّف المهمة",
"loadingModels": "جارٍ تحميل {type}...",
"maxFileSize": "الحد الأقصى لحجم الملف: {size}",

View File

@@ -2633,6 +2633,10 @@
"errorUploadFailed": "Failed to import asset. Please try again.",
"errorUserTokenAccessDenied": "Your API token does not have access to this resource. Please check your token permissions.",
"errorUserTokenInvalid": "Your stored API token is invalid or expired. Please update your token in settings.",
"invalidAsset": "Invalid Asset",
"invalidAssetDetail": "The selected asset could not be validated. Please try again.",
"invalidFilename": "Invalid Filename",
"invalidFilenameDetail": "The asset filename could not be determined. Please try again.",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"fileFormats": "File formats",
"fileName": "File Name",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "Pega el enlace aquí",
"importAnother": "Importar otro",
"imported": "Importado",
"invalidAsset": "Recurso no válido",
"invalidAssetDetail": "No se pudo validar el recurso seleccionado. Por favor, inténtalo de nuevo.",
"invalidFilename": "Nombre de archivo no válido",
"invalidFilenameDetail": "No se pudo determinar el nombre de archivo del recurso. Por favor, inténtalo de nuevo.",
"jobId": "ID de tarea",
"loadingModels": "Cargando {type}...",
"maxFileSize": "Tamaño máximo de archivo: {size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "لینک را اینجا وارد کنید",
"importAnother": "وارد کردن مورد دیگر",
"imported": "وارد شده",
"invalidAsset": "دارایی نامعتبر",
"invalidAssetDetail": "دارایی انتخاب‌شده قابل اعتبارسنجی نیست. لطفاً دوباره تلاش کنید.",
"invalidFilename": "نام فایل نامعتبر",
"invalidFilenameDetail": "نام فایل دارایی قابل شناسایی نیست. لطفاً دوباره تلاش کنید.",
"jobId": "شناسه کار: {jobId}",
"loadingModels": "در حال بارگذاری {type}...",
"maxFileSize": "حداکثر اندازه فایل: {size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "Collez le lien ici",
"importAnother": "Importer un autre",
"imported": "Importé",
"invalidAsset": "Ressource invalide",
"invalidAssetDetail": "La ressource sélectionnée na pas pu être validée. Veuillez réessayer.",
"invalidFilename": "Nom de fichier invalide",
"invalidFilenameDetail": "Le nom de fichier de la ressource na pas pu être déterminé. Veuillez réessayer.",
"jobId": "ID de tâche",
"loadingModels": "Chargement de {type}...",
"maxFileSize": "Taille maximale du fichier : {size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "ここにリンクを貼り付けてください",
"importAnother": "別のファイルをインポート",
"imported": "インポート済み",
"invalidAsset": "無効なアセット",
"invalidAssetDetail": "選択したアセットを検証できませんでした。もう一度お試しください。",
"invalidFilename": "無効なファイル名",
"invalidFilenameDetail": "アセットのファイル名を特定できませんでした。もう一度お試しください。",
"jobId": "ジョブID",
"loadingModels": "{type}を読み込み中...",
"maxFileSize": "最大ファイルサイズ:{size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "여기에 링크를 붙여넣으세요",
"importAnother": "다른 항목 가져오기",
"imported": "가져온 항목",
"invalidAsset": "잘못된 에셋",
"invalidAssetDetail": "선택한 에셋을 확인할 수 없습니다. 다시 시도해 주세요.",
"invalidFilename": "잘못된 파일명",
"invalidFilenameDetail": "에셋의 파일명을 확인할 수 없습니다. 다시 시도해 주세요.",
"jobId": "작업 ID",
"loadingModels": "{type} 불러오는 중...",
"maxFileSize": "최대 파일 크기: {size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "Cole o link aqui",
"importAnother": "Importar outro",
"imported": "Importado",
"invalidAsset": "Ativo inválido",
"invalidAssetDetail": "O ativo selecionado não pôde ser validado. Por favor, tente novamente.",
"invalidFilename": "Nome de arquivo inválido",
"invalidFilenameDetail": "Não foi possível determinar o nome do arquivo do ativo. Por favor, tente novamente.",
"jobId": "ID do trabalho",
"loadingModels": "Carregando {type}...",
"maxFileSize": "Tamanho máximo do arquivo: {size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "Вставьте ссылку сюда",
"importAnother": "Импортировать другой",
"imported": "Импортировано",
"invalidAsset": "Недопустимый ресурс",
"invalidAssetDetail": "Выбранный ресурс не удалось проверить. Пожалуйста, попробуйте еще раз.",
"invalidFilename": "Недопустимое имя файла",
"invalidFilenameDetail": "Не удалось определить имя файла ресурса. Пожалуйста, попробуйте еще раз.",
"jobId": "ID задачи",
"loadingModels": "Загрузка {type}...",
"maxFileSize": "Максимальный размер файла: {size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "Bağlantıyı buraya yapıştırın",
"importAnother": "Başka Birini İçe Aktar",
"imported": "İçe aktarıldı",
"invalidAsset": "Geçersiz Varlık",
"invalidAssetDetail": "Seçilen varlık doğrulanamadı. Lütfen tekrar deneyin.",
"invalidFilename": "Geçersiz Dosya Adı",
"invalidFilenameDetail": "Varlık dosya adı belirlenemedi. Lütfen tekrar deneyin.",
"jobId": "İş ID",
"loadingModels": "{type} yükleniyor...",
"maxFileSize": "Maksimum dosya boyutu: {size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "請在此貼上連結",
"importAnother": "匯入其他",
"imported": "已匯入",
"invalidAsset": "無效的資產",
"invalidAssetDetail": "無法驗證所選資產。請再試一次。",
"invalidFilename": "無效的檔案名稱",
"invalidFilenameDetail": "無法判斷資產檔案名稱。請再試一次。",
"jobId": "工作 ID",
"loadingModels": "正在載入 {type}...",
"maxFileSize": "最大檔案大小:{size}",

View File

@@ -91,6 +91,10 @@
"genericLinkPlaceholder": "粘贴链接到这",
"importAnother": "导入其他",
"imported": "已导入",
"invalidAsset": "无效资源",
"invalidAssetDetail": "所选资源无法验证。请重试。",
"invalidFilename": "无效文件名",
"invalidFilenameDetail": "无法确定资源文件名。请重试。",
"jobId": "任务ID",
"loadingModels": "正在加载{type}...",
"maxFileSize": "最大文件大小:{size}",

View File

@@ -0,0 +1,111 @@
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
interface CreateAssetWidgetParams {
/** The node to add the widget to */
node: LGraphNode
/** The widget name */
widgetName: string
/** The node type to show in asset browser (may differ from node.comfyClass for PrimitiveNode) */
nodeTypeForBrowser: string
/** Input name for asset browser filtering (defaults to widgetName if not provided) */
inputNameForBrowser?: string
/** Default value for the widget */
defaultValue?: string
/** Callback when widget value changes */
onValueChange?: (
widget: IBaseWidget,
newValue: string,
oldValue: unknown
) => void
}
/**
* Creates an asset widget that opens the Asset Browser dialog for model selection.
* Used by both regular nodes (via useComboWidget) and PrimitiveNode.
*
* @param params - Configuration for the asset widget
* @returns The created asset widget
*/
export function createAssetWidget(
params: CreateAssetWidgetParams
): IBaseWidget {
const {
node,
widgetName,
nodeTypeForBrowser,
inputNameForBrowser,
defaultValue,
onValueChange
} = params
const displayLabel = defaultValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
const toastStore = useToastStore()
await assetBrowserDialog.show({
nodeType: nodeTypeForBrowser,
inputName: inputNameForBrowser ?? widgetName,
currentValue: widget.value as string,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidAsset'),
detail: t('assetBrowser.invalidAssetDetail'),
life: 5000
})
return
}
const filename = getAssetFilename(validatedAsset.data)
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidFilename'),
detail: t('assetBrowser.invalidFilenameDetail'),
life: 5000
})
return
}
const oldValue = widget.value
widget.value = validatedFilename.data
onValueChange?.(widget, validatedFilename.data, oldValue)
}
})
}
const options: IWidgetAssetOptions = { openModal }
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
}

View File

@@ -0,0 +1,375 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { t } from '@/i18n'
import {
TOKEN_REFRESH_BUFFER_MS,
WORKSPACE_STORAGE_KEYS
} from '@/platform/auth/workspace/workspaceConstants'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const WorkspaceWithRoleSchema = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
const WorkspaceTokenResponseSchema = z.object({
token: z.string(),
expires_at: z.string(),
workspace: z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team'])
}),
role: z.enum(['owner', 'member']),
permissions: z.array(z.string())
})
export class WorkspaceAuthError extends Error {
constructor(
message: string,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceAuthError'
}
}
export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
const { flags } = useFeatureFlags()
// State
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
const workspaceToken = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
// Timer state
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
let refreshRequestId = 0
// Getters
const isAuthenticated = computed(
() => currentWorkspace.value !== null && workspaceToken.value !== null
)
// Private helpers
function stopRefreshTimer(): void {
if (refreshTimerId !== null) {
clearTimeout(refreshTimerId)
refreshTimerId = null
}
}
function scheduleTokenRefresh(expiresAt: number): void {
stopRefreshTimer()
const now = Date.now()
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
const delay = Math.max(0, refreshAt - now)
refreshTimerId = setTimeout(() => {
void refreshToken()
}, delay)
}
function persistToSession(
workspace: WorkspaceWithRole,
token: string,
expiresAt: number
): void {
try {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(workspace)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, token)
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
expiresAt.toString()
)
} catch {
console.warn('Failed to persist workspace context to sessionStorage')
}
}
function clearSessionStorage(): void {
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
} catch {
console.warn('Failed to clear workspace context from sessionStorage')
}
}
// Actions
function init(): void {
initializeFromSession()
}
function destroy(): void {
stopRefreshTimer()
}
function initializeFromSession(): boolean {
if (!flags.teamWorkspacesEnabled) {
return false
}
try {
const workspaceJson = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
)
const token = sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)
const expiresAtStr = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (!workspaceJson || !token || !expiresAtStr) {
return false
}
const expiresAt = parseInt(expiresAtStr, 10)
if (isNaN(expiresAt) || expiresAt <= Date.now()) {
clearSessionStorage()
return false
}
const parsedWorkspace = JSON.parse(workspaceJson)
const parseResult = WorkspaceWithRoleSchema.safeParse(parsedWorkspace)
if (!parseResult.success) {
clearSessionStorage()
return false
}
currentWorkspace.value = parseResult.data
workspaceToken.value = token
error.value = null
scheduleTokenRefresh(expiresAt)
return true
} catch {
clearSessionStorage()
return false
}
}
async function switchWorkspace(workspaceId: string): Promise<void> {
if (!flags.teamWorkspacesEnabled) {
return
}
// Only increment request ID when switching to a different workspace
// This invalidates stale refresh operations for the old workspace
// but allows refresh operations for the same workspace to complete
if (currentWorkspace.value?.id !== workspaceId) {
refreshRequestId++
}
isLoading.value = true
error.value = null
try {
const firebaseAuthStore = useFirebaseAuthStore()
const firebaseToken = await firebaseAuthStore.getIdToken()
if (!firebaseToken) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.notAuthenticated'),
'NOT_AUTHENTICATED'
)
}
const response = await fetch(api.apiURL('/auth/token'), {
method: 'POST',
headers: {
Authorization: `Bearer ${firebaseToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ workspace_id: workspaceId })
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const message = errorData.message || response.statusText
if (response.status === 401) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.invalidFirebaseToken'),
'INVALID_FIREBASE_TOKEN'
)
}
if (response.status === 403) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.accessDenied'),
'ACCESS_DENIED'
)
}
if (response.status === 404) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.workspaceNotFound'),
'WORKSPACE_NOT_FOUND'
)
}
throw new WorkspaceAuthError(
t('workspaceAuth.errors.tokenExchangeFailed', { error: message }),
'TOKEN_EXCHANGE_FAILED'
)
}
const rawData = await response.json()
const parseResult = WorkspaceTokenResponseSchema.safeParse(rawData)
if (!parseResult.success) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.tokenExchangeFailed', {
error: fromZodError(parseResult.error).message
}),
'TOKEN_EXCHANGE_FAILED'
)
}
const data = parseResult.data
const expiresAt = new Date(data.expires_at).getTime()
if (isNaN(expiresAt)) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.tokenExchangeFailed', {
error: 'Invalid expiry timestamp'
}),
'TOKEN_EXCHANGE_FAILED'
)
}
const workspaceWithRole: WorkspaceWithRole = {
...data.workspace,
role: data.role
}
currentWorkspace.value = workspaceWithRole
workspaceToken.value = data.token
persistToSession(workspaceWithRole, data.token, expiresAt)
scheduleTokenRefresh(expiresAt)
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
throw error.value
} finally {
isLoading.value = false
}
}
async function refreshToken(): Promise<void> {
if (!currentWorkspace.value) {
return
}
const workspaceId = currentWorkspace.value.id
// Capture the current request ID to detect if workspace context changed during refresh
const capturedRequestId = refreshRequestId
const maxRetries = 3
const baseDelayMs = 1000
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Check if workspace context changed since refresh started (user switched workspaces)
if (capturedRequestId !== refreshRequestId) {
console.warn(
'Aborting stale token refresh: workspace context changed during refresh'
)
return
}
try {
await switchWorkspace(workspaceId)
return
} catch (err) {
const isAuthError = err instanceof WorkspaceAuthError
const isPermanentError =
isAuthError &&
(err.code === 'ACCESS_DENIED' ||
err.code === 'WORKSPACE_NOT_FOUND' ||
err.code === 'INVALID_FIREBASE_TOKEN' ||
err.code === 'NOT_AUTHENTICATED')
if (isPermanentError) {
// Only clear context if this refresh is still for the current workspace
if (capturedRequestId === refreshRequestId) {
console.error('Workspace access revoked or auth invalid:', err)
clearWorkspaceContext()
}
return
}
const isTransientError =
isAuthError && err.code === 'TOKEN_EXCHANGE_FAILED'
if (isTransientError && attempt < maxRetries) {
const delay = baseDelayMs * Math.pow(2, attempt)
console.warn(
`Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
err
)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
// Only clear context if this refresh is still for the current workspace
if (capturedRequestId === refreshRequestId) {
console.error('Failed to refresh workspace token after retries:', err)
clearWorkspaceContext()
}
}
}
}
function getWorkspaceAuthHeader(): AuthHeader | null {
if (!workspaceToken.value) {
return null
}
return {
Authorization: `Bearer ${workspaceToken.value}`
}
}
function clearWorkspaceContext(): void {
// Increment request ID to invalidate any in-flight stale refresh operations
refreshRequestId++
stopRefreshTimer()
currentWorkspace.value = null
workspaceToken.value = null
error.value = null
clearSessionStorage()
}
return {
// State
currentWorkspace,
workspaceToken,
isLoading,
error,
// Getters
isAuthenticated,
// Actions
init,
destroy,
initializeFromSession,
switchWorkspace,
refreshToken,
getWorkspaceAuthHeader,
clearWorkspaceContext
}
})

View File

@@ -0,0 +1,108 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('subscription.cancelDialog.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')"
:disabled="isLoading"
@click="onClose"
>
<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">
{{ description }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
{{ $t('subscription.cancelDialog.keepSubscription') }}
</Button>
<Button
variant="destructive"
size="lg"
:loading="isLoading"
@click="onConfirmCancel"
>
{{ $t('subscription.cancelDialog.confirmCancel') }}
</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 { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
cancelAt?: string
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const isLoading = ref(false)
const formattedEndDate = computed(() => {
const dateStr = props.cancelAt ?? subscription.value?.endDate
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
})
const description = computed(() =>
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
)
function onClose() {
if (isLoading.value) return
dialogStore.closeDialog({ key: 'cancel-subscription' })
}
async function onConfirmCancel() {
isLoading.value = true
try {
await cancelSubscription()
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
@@ -15,6 +15,7 @@ const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
const mockUserId = ref<string | undefined>('user-123')
const mockGetFirebaseAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
@@ -55,10 +56,11 @@ vi.mock('@/composables/useErrorHandling', () => ({
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getFirebaseAuthHeader: mockGetFirebaseAuthHeader,
userId: 'user-123'
}),
useFirebaseAuthStore: () =>
reactive({
getFirebaseAuthHeader: mockGetFirebaseAuthHeader,
userId: computed(() => mockUserId.value)
}),
FirebaseAuthStoreError: class extends Error {}
}))
@@ -151,6 +153,7 @@ describe('PricingTable', () => {
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
mockUserId.value = 'user-123'
mockTrackBeginCheckout.mockReset()
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
@@ -201,6 +204,33 @@ describe('PricingTable', () => {
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
})
it('should use the latest userId value when it changes after mount', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
mockUserId.value = 'user-early'
const wrapper = createWrapper()
await flushPromises()
mockUserId.value = 'user-late'
const creatorButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Creator'))
await creatorButton?.trigger('click')
await flushPromises()
expect(mockTrackBeginCheckout).toHaveBeenCalledTimes(1)
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-late',
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
previous_tier: 'standard'
})
})
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'

View File

@@ -243,6 +243,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { storeToRefs } from 'pinia'
import Popover from 'primevue/popover'
import SelectButton from 'primevue/selectbutton'
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
@@ -333,7 +334,7 @@ const tiers: PricingTierConfig[] = [
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
useSubscription()
const telemetry = useTelemetry()
const { userId } = useFirebaseAuthStore()
const { userId } = storeToRefs(useFirebaseAuthStore())
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -415,9 +416,9 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
try {
if (isActiveSubscription.value) {
const checkoutAttribution = getCheckoutAttribution()
if (userId) {
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId,
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle.value,
checkout_type: 'change',

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
import { performSubscriptionCheckout } from './subscriptionCheckoutUtil'
@@ -15,7 +16,7 @@ const {
mockGetAuthHeader: vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
),
mockUserId: { value: 'user-123' },
mockUserId: { value: 'user-123' as string | undefined },
mockIsCloud: { value: true },
mockGetCheckoutAttribution: vi.fn(() => ({
ga_client_id: 'ga-client-id',
@@ -32,12 +33,12 @@ vi.mock('@/platform/telemetry', () => ({
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getFirebaseAuthHeader: mockGetAuthHeader,
get userId() {
return mockUserId.value
}
})),
useFirebaseAuthStore: vi.fn(() =>
reactive({
getFirebaseAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
})
),
FirebaseAuthStoreError: class extends Error {}
}))
@@ -53,6 +54,15 @@ vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
global.fetch = vi.fn()
function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('performSubscriptionCheckout', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -105,4 +115,35 @@ describe('performSubscriptionCheckout', () => {
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const authHeader = createDeferred<{ Authorization: string }>()
mockUserId.value = 'user-early'
mockGetAuthHeader.mockImplementationOnce(() => authHeader.promise)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
await checkoutPromise
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledTimes(1)
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new'
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
})

View File

@@ -1,3 +1,5 @@
import { storeToRefs } from 'pinia'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
@@ -37,9 +39,10 @@ export async function performSubscriptionCheckout(
): Promise<void> {
if (!isCloud) return
const { getFirebaseAuthHeader, userId } = useFirebaseAuthStore()
const firebaseAuthStore = useFirebaseAuthStore()
const { userId } = storeToRefs(firebaseAuthStore)
const telemetry = useTelemetry()
const authHeader = await getFirebaseAuthHeader()
const authHeader = await firebaseAuthStore.getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
@@ -84,9 +87,9 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
if (userId) {
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId,
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',

View File

@@ -0,0 +1,20 @@
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
const EXTENDED_TIER_TO_KEY: Record<string, TierKey> = {
...TIER_TO_KEY,
FOUNDER: 'founder'
}
export function formatTierName(
tier: string | null | undefined,
isYearly: boolean,
t: (key: string, params?: Record<string, string>) => string
): string {
if (!tier) return ''
const key = EXTENDED_TIER_TO_KEY[tier] ?? 'standard'
const baseName = t(`subscription.tiers.${key}.name`)
return isYearly
? t('subscription.tierNameYearly', { name: baseName })
: baseName
}

View File

@@ -211,6 +211,7 @@ export interface BillingStatusResponse {
billing_status?: BillingStatus
has_funds: boolean
cancel_at?: string
renewal_date?: string
}
export interface BillingBalanceResponse {

View File

@@ -0,0 +1,112 @@
<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()
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,182 @@
<template>
<div
class="flex w-full max-w-[512px] 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">
{{
step === 'email'
? $t('workspacePanel.inviteMemberDialog.title')
: $t('workspacePanel.inviteMemberDialog.linkStep.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: Email Step -->
<template v-if="step === 'email'">
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.message') }}
</p>
<input
v-model="email"
type="email"
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.inviteMemberDialog.placeholder')"
/>
</div>
<!-- Footer: Email Step -->
<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="!isValidEmail"
@click="onCreateLink"
>
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
</Button>
</div>
</template>
<!-- Body: Link Step -->
<template v-else>
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
</p>
<p class="m-0 text-sm font-medium text-base-foreground">
{{ email }}
</p>
<div class="relative">
<input
:value="generatedLink"
readonly
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</div>
</div>
<!-- Footer: Link Step -->
<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" @click="onCopyLink">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
</Button>
</div>
</template>
</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 dialogStore = useDialogStore()
const toast = useToast()
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
function onCancel() {
dialogStore.closeDialog({ key: 'invite-member' })
}
async function onCreateLink() {
if (!isValidEmail.value) return
loading.value = true
try {
generatedLink.value = await workspaceStore.createInviteLink(email.value)
step.value = 'link'
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
life: 3000
})
}
}
function onSelectLink(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
</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

@@ -0,0 +1,83 @@
<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.removeMemberDialog.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.removeMemberDialog.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="onRemove">
{{ $t('workspacePanel.removeMemberDialog.remove') }}
</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 { memberId } = defineProps<{
memberId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'remove-member' })
}
async function onRemove() {
loading.value = true
try {
await workspaceStore.removeMember(memberId)
toast.add({
severity: 'success',
summary: t('workspacePanel.removeMemberDialog.success'),
life: 2000
})
dialogStore.closeDialog({ key: 'remove-member' })
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.removeMemberDialog.error'),
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,79 @@
<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.revokeInviteDialog.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.revokeInviteDialog.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="onRevoke">
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
</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 { inviteId } = defineProps<{
inviteId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'revoke-invite' })
}
async function onRevoke() {
loading.value = true
try {
await workspaceStore.revokeInvite(inviteId)
dialogStore.closeDialog({ key: 'revoke-invite' })
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,300 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</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"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>
<!-- Warnings -->
<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('subscription.addCredits') }}
</Button>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { captureException } from '@sentry/vue'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false)
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}
function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
if (loading.value || !isValidAmount.value) return
loading.value = true
try {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
const amountCents = payAmount.value * 100
const response = await workspaceApi.createTopup(amountCents)
if (response.status === 'completed') {
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
life: 5000
})
void fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {
captureException(
new Error(`Unexpected topup response status: ${response.status}`)
)
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.unknownError'),
life: 5000
})
}
} catch (error) {
captureException(error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

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 '@/platform/workspace/components/WorkspacePanelContent.vue'
</script>

View File

@@ -0,0 +1,238 @@
<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 unstyled :value="activeTab" @update:value="setActiveTab">
<div class="flex w-full items-center">
<TabList unstyled class="flex w-full gap-2">
<Tab
value="plan"
:class="
cn(
buttonVariants({
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'plan' && 'text-base-foreground no-underline'
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</Tab>
<Tab
value="members"
:class="
cn(
buttonVariants({
variant: activeTab === 'members' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'members' && 'text-base-foreground no-underline',
'ml-2'
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: isInPersonalWorkspace ? 1 : members.length
})
}}
</Tab>
</TabList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus ml-1 text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
class="ml-2"
variant="secondary"
size="lg"
: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' : 'cursor-pointer'
]"
@click="
item.command?.({
originalEvent: $event,
item
})
"
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</template>
</Menu>
</template>
</div>
<TabPanels unstyled>
<TabPanel value="plan">
<SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</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 MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { buttonVariants } from '@/components/ui/button/button.variants'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil'
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,
showInviteMemberDialog,
showEditWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
members,
isInviteLimitReached,
isWorkspaceSubscribed,
isInPersonalWorkspace
} = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, 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 inviteTooltip = computed(() => {
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
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)
fetchMembers()
fetchPendingInvites()
})
</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

@@ -0,0 +1,311 @@
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from '@/composables/billing/types'
/**
* Adapter for workspace-scoped billing via /billing/* endpoints.
* Used for team workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useWorkspaceBilling(): BillingState & BillingActions {
const billingPlans = useBillingPlans()
const workspaceStore = useTeamWorkspaceStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const statusData = shallowRef<BillingStatusResponse | null>(null)
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
const isActiveSubscription = computed(
() => statusData.value?.is_active ?? false
)
const subscription = computed<SubscriptionInfo | null>(() => {
const status = statusData.value
if (!status) return null
return {
isActive: status.is_active,
tier: status.subscription_tier ?? null,
duration: status.subscription_duration ?? null,
planSlug: status.plan_slug ?? null,
renewalDate: null, // Workspace billing uses cancel_at for end date
endDate: status.cancel_at ?? null,
isCancelled: status.subscription_status === 'canceled',
hasFunds: status.has_funds
}
})
const balance = computed<BalanceInfo | null>(() => {
const data = balanceData.value
if (!data) return null
return {
amountMicros: data.amount_micros,
currency: data.currency,
effectiveBalanceMicros: data.effective_balance_micros,
prepaidBalanceMicros: data.prepaid_balance_micros,
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
}
})
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
)
const pendingCancelOpId = ref<string | null>(null)
let cancelPollTimeout: number | null = null
const stopCancelPolling = () => {
if (cancelPollTimeout !== null) {
window.clearTimeout(cancelPollTimeout)
cancelPollTimeout = null
}
}
async function pollCancelStatus(opId: string): Promise<void> {
stopCancelPolling()
const maxAttempts = 30
let attempt = 0
const poll = async () => {
if (pendingCancelOpId.value !== opId) return
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
workspaceStore.updateActiveWorkspace({
isSubscribed: false
})
return
}
if (response.status === 'failed') {
pendingCancelOpId.value = null
stopCancelPolling()
throw new Error(
response.error_message ?? 'Failed to cancel subscription'
)
}
attempt += 1
if (attempt >= maxAttempts) {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
return
}
} catch (err) {
pendingCancelOpId.value = null
stopCancelPolling()
throw err
}
cancelPollTimeout = window.setTimeout(
() => {
void poll()
},
Math.min(1000 * 2 ** attempt, 5000)
)
}
await poll()
}
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
statusData.value = await workspaceApi.getBillingStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch billing status'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
balanceData.value = await workspaceApi.getBillingBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.subscribe(
planSlug,
returnUrl,
cancelUrl
)
// Refresh status and balance after subscription
await Promise.all([fetchStatus(), fetchBalance()])
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
throw err
} finally {
isLoading.value = false
}
}
async function previewSubscribe(
planSlug: string
): Promise<PreviewSubscribeResponse | null> {
isLoading.value = true
error.value = null
try {
return await workspaceApi.previewSubscribe(planSlug)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to preview subscription'
throw err
} finally {
isLoading.value = false
}
}
async function manageSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const returnUrl = window.location.href
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
if (response.url) {
window.open(response.url, '_blank')
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to open billing portal'
throw err
} finally {
isLoading.value = false
}
}
async function cancelSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.cancelSubscription()
pendingCancelOpId.value = response.billing_op_id
await pollCancelStatus(response.billing_op_id)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to cancel subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
try {
await billingPlans.fetchPlans()
if (billingPlans.error.value) {
error.value = billingPlans.error.value
}
} finally {
isLoading.value = false
}
}
const subscriptionDialog = useSubscriptionDialog()
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
}
onBeforeUnmount(() => {
stopCancelPolling()
})
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -0,0 +1,504 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
import type { BillingOpStatusResponse } from '@/platform/workspace/api/workspaceApi'
const mockFetchStatus = vi.fn()
const mockFetchBalance = vi.fn()
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
})
}))
const mockToastAdd = vi.fn()
const mockToastRemove = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd,
remove: mockToastRemove
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingOpStatus: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showSettingsDialog: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from './billingOperationStore'
describe('billingOperationStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('startOperation', () => {
it('creates a pending operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.operations.size).toBe(1)
const operation = store.getOperation('op-1')
expect(operation).toBeDefined()
expect(operation?.status).toBe('pending')
expect(operation?.type).toBe('subscription')
expect(store.hasPendingOperations).toBe(true)
})
it('does not create duplicate operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-1', 'topup')
expect(store.operations.size).toBe(1)
expect(store.getOperation('op-1')?.type).toBe('subscription')
})
it('shows immediate processing toast for subscription operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.subscriptionProcessing',
group: 'billing-operation'
})
})
it('shows immediate processing toast for topup operations', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'info',
summary: 'billingOperation.topupProcessing',
group: 'billing-operation'
})
})
})
describe('polling success', () => {
it('updates status and shows toast on success', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('succeeded')
expect(store.hasPendingOperations).toBe(false)
expect(mockFetchStatus).toHaveBeenCalled()
expect(mockFetchBalance).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.subscriptionSuccess',
life: 5000
})
})
it('shows topup success message for topup operations', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'billingOperation.topupSuccess',
life: 5000
})
})
it('removes the received toast when operation succeeds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
const receivedToast = mockToastAdd.mock.calls[0][0]
await vi.advanceTimersByTimeAsync(0)
expect(mockToastRemove).toHaveBeenCalledWith(receivedToast)
})
})
describe('polling failure', () => {
it('updates status and shows error toast on failure', async () => {
const errorMessage = 'Payment declined'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
error_message: errorMessage,
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('failed')
expect(operation?.errorMessage).toBe(errorMessage)
expect(store.hasPendingOperations).toBe(false)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionFailed',
detail: errorMessage,
life: 5000
})
})
it('uses default message when no error_message in response', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'failed',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupFailed',
detail: undefined,
life: 5000
})
})
})
describe('polling timeout', () => {
it('times out after 2 minutes and shows error toast', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
const operation = store.getOperation('op-1')
expect(operation?.status).toBe('timeout')
expect(store.hasPendingOperations).toBe(false)
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionTimeout',
life: 5000
})
})
it('shows topup timeout message for topup operations', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(121_000)
await vi.runAllTimersAsync()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupTimeout',
life: 5000
})
})
})
describe('exponential backoff', () => {
it('uses exponential backoff for polling intervals', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1500)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(2250)
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
})
it('caps polling interval at 8 seconds', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(60_000)
const callCountBefore = vi.mocked(workspaceApi.getBillingOpStatus).mock
.calls.length
await vi.advanceTimersByTimeAsync(8000)
expect(
vi.mocked(workspaceApi.getBillingOpStatus).mock.calls.length
).toBeGreaterThan(callCountBefore)
})
})
describe('network errors', () => {
it('continues polling on network errors', async () => {
vi.mocked(workspaceApi.getBillingOpStatus)
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
} satisfies BillingOpStatusResponse)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.getOperation('op-1')?.status).toBe('pending')
await vi.advanceTimersByTimeAsync(1500)
expect(store.getOperation('op-1')?.status).toBe('pending')
await vi.advanceTimersByTimeAsync(2250)
expect(store.getOperation('op-1')?.status).toBe('succeeded')
})
})
describe('clearOperation', () => {
it('removes operation from the store', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.operations.size).toBe(1)
store.clearOperation('op-1')
expect(store.operations.size).toBe(0)
expect(store.getOperation('op-1')).toBeUndefined()
})
})
describe('multiple operations', () => {
it('can track multiple operations concurrently', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
async (opId: string) => ({
id: opId,
status: 'pending' as const,
started_at: new Date().toISOString()
})
)
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
store.startOperation('op-2', 'topup')
expect(store.operations.size).toBe(2)
expect(store.hasPendingOperations).toBe(true)
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
async (opId: string) => ({
id: opId,
status:
opId === 'op-1' ? ('succeeded' as const) : ('pending' as const),
started_at: new Date().toISOString()
})
)
await vi.advanceTimersByTimeAsync(1500)
expect(store.getOperation('op-1')?.status).toBe('succeeded')
expect(store.getOperation('op-2')?.status).toBe('pending')
expect(store.hasPendingOperations).toBe(true)
})
})
describe('isSettingUp', () => {
it('returns true when there is a pending subscription operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.isSettingUp).toBe(true)
})
it('returns false when there is no pending subscription operation', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
await vi.advanceTimersByTimeAsync(0)
expect(store.isSettingUp).toBe(false)
})
it('returns false when only topup operations are pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(store.isSettingUp).toBe(false)
})
})
describe('isAddingCredits', () => {
it('returns true when there is a pending topup operation', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
expect(store.isAddingCredits).toBe(true)
})
it('returns false when there is no pending topup operation', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'succeeded',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)
expect(store.isAddingCredits).toBe(false)
})
it('returns false when only subscription operations are pending', () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString()
})
const store = useBillingOperationStore()
store.startOperation('op-1', 'subscription')
expect(store.isAddingCredits).toBe(false)
})
})
})

View File

@@ -0,0 +1,244 @@
import type { ToastMessageOptions } from 'primevue/toast'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const INITIAL_INTERVAL_MS = 1000
const MAX_INTERVAL_MS = 8000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000 // 2 minutes
type OperationType = 'subscription' | 'topup'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
interface BillingOperation {
opId: string
type: OperationType
status: OperationStatus
errorMessage: string | null
startedAt: number
}
export const useBillingOperationStore = defineStore('billingOperation', () => {
const operations = ref<Map<string, BillingOperation>>(new Map())
const timeouts = new Map<string, ReturnType<typeof setTimeout>>()
const intervals = new Map<string, number>()
const receivedToasts = new Map<string, ToastMessageOptions>()
const hasPendingOperations = computed(() =>
[...operations.value.values()].some((op) => op.status === 'pending')
)
const isSettingUp = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'subscription'
)
)
const isAddingCredits = computed(() =>
[...operations.value.values()].some(
(op) => op.status === 'pending' && op.type === 'topup'
)
)
function getOperation(opId: string) {
return operations.value.get(opId)
}
function startOperation(opId: string, type: OperationType) {
if (operations.value.has(opId)) return
const operation: BillingOperation = {
opId,
type,
status: 'pending',
errorMessage: null,
startedAt: Date.now()
}
operations.value = new Map(operations.value).set(opId, operation)
intervals.set(opId, INITIAL_INTERVAL_MS)
// Show immediate feedback toast (persists until operation completes)
const messageKey =
type === 'subscription'
? 'billingOperation.subscriptionProcessing'
: 'billingOperation.topupProcessing'
const toastMessage: ToastMessageOptions = {
severity: 'info',
summary: t(messageKey),
group: 'billing-operation'
}
receivedToasts.set(opId, toastMessage)
useToastStore().add(toastMessage)
void poll(opId)
}
async function poll(opId: string) {
const operation = operations.value.get(opId)
if (!operation || operation.status !== 'pending') return
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
handleTimeout(opId)
return
}
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
await handleSuccess(opId)
return
}
if (response.status === 'failed') {
handleFailure(opId, response.error_message ?? null)
return
}
scheduleNextPoll(opId)
} catch {
if (Date.now() - operation.startedAt > TIMEOUT_MS) {
handleTimeout(opId)
return
}
scheduleNextPoll(opId)
}
}
function scheduleNextPoll(opId: string) {
const currentInterval = intervals.get(opId) ?? INITIAL_INTERVAL_MS
const nextInterval = Math.min(
currentInterval * BACKOFF_MULTIPLIER,
MAX_INTERVAL_MS
)
intervals.set(opId, nextInterval)
const timeoutId = setTimeout(() => void poll(opId), nextInterval)
timeouts.set(opId, timeoutId)
}
async function handleSuccess(opId: string) {
const operation = operations.value.get(opId)
if (!operation) return
updateOperationStatus(opId, 'succeeded', null)
cleanup(opId)
const billingContext = useBillingContext()
await Promise.all([
billingContext.fetchStatus(),
billingContext.fetchBalance()
])
// Close any open billing dialogs and show settings
const dialogStore = useDialogStore()
dialogStore.closeDialog({ key: 'subscription-required' })
dialogStore.closeDialog({ key: 'top-up-credits' })
void useDialogService().showSettingsDialog('workspace')
const toastStore = useToastStore()
const messageKey =
operation.type === 'subscription'
? 'billingOperation.subscriptionSuccess'
: 'billingOperation.topupSuccess'
toastStore.add({
severity: 'success',
summary: t(messageKey),
life: 5000
})
}
function handleFailure(opId: string, errorMessage: string | null) {
const operation = operations.value.get(opId)
if (!operation) return
const defaultMessage =
operation.type === 'subscription'
? t('billingOperation.subscriptionFailed')
: t('billingOperation.topupFailed')
updateOperationStatus(opId, 'failed', errorMessage ?? defaultMessage)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: defaultMessage,
detail: errorMessage ?? undefined,
life: 5000
})
}
function handleTimeout(opId: string) {
const operation = operations.value.get(opId)
if (!operation) return
const message =
operation.type === 'subscription'
? t('billingOperation.subscriptionTimeout')
: t('billingOperation.topupTimeout')
updateOperationStatus(opId, 'timeout', message)
cleanup(opId)
useToastStore().add({
severity: 'error',
summary: message,
life: 5000
})
}
function updateOperationStatus(
opId: string,
status: OperationStatus,
errorMessage: string | null
) {
const operation = operations.value.get(opId)
if (!operation) return
const updated = { ...operation, status, errorMessage }
operations.value = new Map(operations.value).set(opId, updated)
}
function cleanup(opId: string) {
const timeoutId = timeouts.get(opId)
if (timeoutId) {
clearTimeout(timeoutId)
timeouts.delete(opId)
}
intervals.delete(opId)
// Remove the "received" toast
const receivedToast = receivedToasts.get(opId)
if (receivedToast) {
useToastStore().remove(receivedToast)
receivedToasts.delete(opId)
}
}
function clearOperation(opId: string) {
cleanup(opId)
const newMap = new Map(operations.value)
newMap.delete(opId)
operations.value = newMap
}
return {
operations,
hasPendingOperations,
isSettingUp,
isAddingCredits,
getOperation,
startOperation,
clearOperation
}
})

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -13,15 +14,24 @@ const props = defineProps<{
const domEl = ref<HTMLElement>()
const { canvas } = useCanvasStore()
onMounted(() => {
const canvasStore = useCanvasStore()
const { canvas } = canvasStore
function mountWidgetElement() {
if (!domEl.value) return
const node = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
if (!node) return
const widget = node.widgets?.find((w) => w.name === props.widget.name)
if (!widget || !isDOMWidget(widget)) return
if (domEl.value.contains(widget.element)) return
domEl.value.replaceChildren(widget.element)
}
onMounted(() => {
mountWidgetElement()
})
whenever(() => !canvasStore.linearMode, mountWidgetElement)
</script>
<template>
<div ref="domEl" @pointerdown.stop @pointermove.stop @pointerup.stop />

View File

@@ -3,18 +3,10 @@ import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type {
@@ -90,71 +82,20 @@ const addMultiSelectWidget = (
return widget
}
const createAssetBrowserWidget = (
function createAssetBrowserWidget(
node: LGraphNode,
inputSpec: ComboInputSpec,
defaultValue: string | undefined
): IBaseWidget => {
const currentValue = defaultValue
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
): IBaseWidget {
return createAssetWidget({
node,
widgetName: inputSpec.name,
nodeTypeForBrowser: node.comfyClass ?? '',
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
node.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = getAssetFilename(validatedAsset.data)
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
widget.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
const options: IWidgetAssetOptions = { openModal }
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
() => undefined,
options
)
return widget
})
}
const createInputMappingWidget = (

View File

@@ -61,6 +61,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
@@ -86,6 +87,10 @@ import {
fixLinkInputSlots,
isImageNode
} from '@/utils/litegraphUtil'
import {
createSharedObjectUrl,
releaseSharedObjectUrl
} from '@/utils/objectUrlUtil'
import {
findLegacyRerouteNodes,
noNativeReroutes
@@ -701,12 +706,13 @@ export class ComfyApp {
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
// Enhanced preview with explicit node context
const { blob, displayNodeId } = detail
const { blob, displayNodeId, promptId } = detail
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
useNodeOutputStore()
const blobUrl = createSharedObjectUrl(blob)
useJobPreviewStore().setPreviewUrl(promptId, blobUrl)
// Ensure clean up if `executing` event is missed.
revokePreviewsByExecutionId(displayNodeId)
const blobUrl = URL.createObjectURL(blob)
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
const nodeParents = displayNodeId.split(':')
for (let i = 1; i <= nodeParents.length; i++) {
@@ -714,6 +720,7 @@ export class ComfyApp {
blobUrl
])
}
releaseSharedObjectUrl(blobUrl)
})
api.init()

View File

@@ -124,7 +124,9 @@ export function getWebpMetadata(file: File) {
break
}
offset += 8 + chunk_length
// RIFF spec requires odd-sized chunks to be padded with a single byte
// https://developers.google.com/speed/webp/docs/riff_container#riff_file_format
offset += 8 + chunk_length + (chunk_length % 2)
}
r(txt_chunks)

View File

@@ -30,6 +30,7 @@ import type {
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
@@ -454,6 +455,7 @@ export const useExecutionStore = defineStore('execution', () => {
const map = { ...nodeProgressStatesByPrompt.value }
delete map[promptId]
nodeProgressStatesByPrompt.value = map
useJobPreviewStore().clearPreview(promptId)
}
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]

View File

@@ -15,6 +15,10 @@ import { useExecutionStore } from '@/stores/executionStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isVideoNode } from '@/utils/litegraphUtil'
import {
releaseSharedObjectUrl,
retainSharedObjectUrl
} from '@/utils/objectUrlUtil'
const PREVIEW_REVOKE_DELAY_MS = 400
@@ -216,10 +220,19 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
if (existingPreviews?.[Symbol.iterator]) {
for (const url of existingPreviews) {
releaseSharedObjectUrl(url)
}
}
for (const url of previewImages) {
retainSharedObjectUrl(url)
}
latestPreview.value = previewImages
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
@@ -237,10 +250,19 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
previewImages: string[]
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
if (existingPreviews?.[Symbol.iterator]) {
for (const url of existingPreviews) {
releaseSharedObjectUrl(url)
}
}
for (const url of previewImages) {
retainSharedObjectUrl(url)
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
@@ -270,7 +292,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
if (!previews?.[Symbol.iterator]) return
for (const url of previews) {
URL.revokeObjectURL(url)
releaseSharedObjectUrl(url)
}
delete app.nodePreviewImages[nodeLocatorId]
@@ -287,7 +309,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
if (!previews?.[Symbol.iterator]) continue
for (const url of previews) {
URL.revokeObjectURL(url)
releaseSharedObjectUrl(url)
}
}
app.nodePreviewImages = {}
@@ -326,6 +348,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// Clear preview images
if (app.nodePreviewImages[nodeLocatorId]) {
const previews = app.nodePreviewImages[nodeLocatorId]
if (previews?.[Symbol.iterator]) {
for (const url of previews) {
releaseSharedObjectUrl(url)
}
}
delete app.nodePreviewImages[nodeLocatorId]
delete nodePreviewImages.value[nodeLocatorId]
}

View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia'
import { computed, readonly, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import {
releaseSharedObjectUrl,
retainSharedObjectUrl
} from '@/utils/objectUrlUtil'
type PromptPreviewMap = Record<string, string>
export const useJobPreviewStore = defineStore('jobPreview', () => {
const settingStore = useSettingStore()
const previewsByPromptId = ref<PromptPreviewMap>({})
const readonlyPreviewsByPromptId = readonly(previewsByPromptId)
const previewMethod = computed(() =>
settingStore.get('Comfy.Execution.PreviewMethod')
)
const isPreviewEnabled = computed(() => previewMethod.value !== 'none')
function setPreviewUrl(promptId: string | undefined, url: string) {
if (!promptId || !isPreviewEnabled.value) return
const current = previewsByPromptId.value[promptId]
if (current === url) return
if (current) releaseSharedObjectUrl(current)
retainSharedObjectUrl(url)
previewsByPromptId.value = {
...previewsByPromptId.value,
[promptId]: url
}
}
function clearPreview(promptId: string | undefined) {
if (!promptId) return
const current = previewsByPromptId.value[promptId]
if (!current) return
releaseSharedObjectUrl(current)
const next = { ...previewsByPromptId.value }
delete next[promptId]
previewsByPromptId.value = next
}
function clearAllPreviews() {
Object.values(previewsByPromptId.value).forEach((url) => {
releaseSharedObjectUrl(url)
})
previewsByPromptId.value = {}
}
watch(isPreviewEnabled, (enabled) => {
if (!enabled) clearAllPreviews()
})
return {
previewsByPromptId: readonlyPreviewsByPromptId,
isPreviewEnabled,
setPreviewUrl,
clearPreview,
clearAllPreviews
}
})

View File

@@ -0,0 +1,27 @@
const objectUrlRefCounts = new Map<string, number>()
const isBlobUrl = (url: string) => url.startsWith('blob:')
export function createSharedObjectUrl(blob: Blob): string {
const url = URL.createObjectURL(blob)
objectUrlRefCounts.set(url, 1)
return url
}
export function retainSharedObjectUrl(url: string | undefined): void {
if (!url || !isBlobUrl(url)) return
objectUrlRefCounts.set(url, (objectUrlRefCounts.get(url) ?? 0) + 1)
}
export function releaseSharedObjectUrl(url: string | undefined): void {
if (!url || !isBlobUrl(url)) return
const currentCount = objectUrlRefCounts.get(url)
if (currentCount === undefined || currentCount <= 1) {
objectUrlRefCounts.delete(url)
URL.revokeObjectURL(url)
return
}
objectUrlRefCounts.set(url, currentCount - 1)
}

View File

@@ -1,6 +0,0 @@
import baseConfig from '@comfyorg/design-system/tailwind-config'
export default {
...baseConfig,
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
}

View File

@@ -45,7 +45,7 @@
"src/types/**/*.d.ts",
"playwright.config.ts",
"playwright.i18n.config.ts",
"tailwind.config.ts",
"tests-ui/**/*",
"vite.config.mts",
"vitest.config.ts"