Compare commits

..

9 Commits

Author SHA1 Message Date
Benjamin Lu
13336cc5a0 Fix share auth attribution gap 2026-06-22 13:33:35 -07:00
jaeone94
56b05c0fd5 chore: shrink-wrap asset browser dialog chrome (#13060)
## Summary

- Shrink-wraps the asset browser dialog chrome around its self-sized
BaseModalLayout content.
- Applies the same Reka dialog props to model-widget selection and
direct asset browsing entry points.

## Cause

The Reka dialog cutover moved showLayoutDialog callers onto the shared
Reka DialogContent wrapper. AssetBrowserModal already renders a large
BaseModalLayout with its own modal sizing, but the outer wrapper still
used the default md dialog width. That left the large modal content
anchored from a narrow centered wrapper, pushing the right edge beyond
the viewport.

This change keeps BaseModalLayout as the owner of the asset browser
dimensions. The Reka wrapper now only shrink-wraps the content and
removes its own border, background, and shadow.

## Validation

- pnpm exec oxfmt --write
src/platform/assets/composables/useAssetBrowserDialog.ts
- pnpm exec eslint
src/platform/assets/composables/useAssetBrowserDialog.ts
- pnpm typecheck
- pre-commit hook completed oxfmt, oxlint, eslint, and typecheck

## Screenshot
Before 
<img width="1920" height="1028" alt="스크린샷 2026-06-22 오후 9 30 15"
src="https://github.com/user-attachments/assets/1534a0a8-a239-419e-b05f-9c5e43cedeb1"
/>

After
<img width="1918" height="1024" alt="스크린샷 2026-06-22 오후 9 29 39"
src="https://github.com/user-attachments/assets/14ad751e-54c9-4f9e-87f5-805f6ca456d1"
/>
2026-06-22 15:52:33 +00:00
pythongosssss
403353ac77 feat: add tab status indicator (running/done/errored) (#10177)
## Summary

Adds indicator to show outcome of last job per tab, cleared next time
the workflow is activated.

## Changes

- **What**: 
- add workflow status tracking to execution store, handling various
events
- add icon to tab based on store
- handle race condition where job finishes instantly (e.g. invalid
workflow or already executed)

## Screenshots (if applicable)



https://github.com/user-attachments/assets/8b1d8d8e-57d4-4ac2-9cc3-0d218d6eb0f7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10177-feat-add-tab-status-indicator-running-done-errored-3266d73d365081a89f5dfd58487bb065)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-22 09:22:14 +00:00
Christian Byrne
c4db198875 fix(test): de-flake Canvas ctrl+shift+vertical-drag zoom e2e (#13024)
## Observed flake

The e2e test `Canvas Interaction > Can zoom in/out with
ctrl+shift+vertical-drag` (`browser_tests/tests/interaction.spec.ts`)
intermittently failed in CI with:

```
Error: mouse.move: Test timeout of 15000ms exceeded
```

It failed all 3 retries on a single shard while 209 other tests on that
shard passed, and passed on a later re-run — i.e. genuinely flaky, not a
hard break.

## Root cause

The test pressed `Control` and `Shift` *down* once, then ran three
`canvasOps.dragAndDrop` gestures (each performs a `mouse.move(target, {
steps: 100 })`) with three `toHaveScreenshot` assertions interleaved,
and only released the modifiers at the very end — with no `try/finally`.

Two problems:
1. Holding the modifiers across all three drags means every one of the
~300 step-wise `mousemove` events drives litegraph's ctrl+shift zoom
handler (scale recompute + canvas redraw). Combined with the screenshot
captures in between, the main thread can saturate, and a single
`mouse.move` step can stall past the 15s test timeout. That is exactly
the failing call in the signature.
2. Without `try/finally`, a mid-test failure leaves `Control`/`Shift`
stuck down.

## Fix

Switch to the existing `canvasOps.ctrlShiftDrag(from, to)` helper, which
presses and releases `Control`+`Shift` around each individual gesture.
This is the robust pattern already used in `canvasSettings.spec.ts`. The
modifiers are never held across the heavy multi-drag + screenshot
sequence, and are always released.

Test intent, drag coordinates, and all three screenshot assertions are
unchanged.

## Validation

- Verified by reasoning: `ctrlShiftDrag` wraps the same `dragAndDrop`
with `keyboard.down/up` of the same modifiers, called with identical
`Position` args, so behavior and types are preserved.
- Could not run the browser e2e locally (requires a running ComfyUI
backend + Playwright browsers; `node_modules` not installed in this
environment). Relying on CI for the full e2e run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-20 03:32:41 +00:00
Christian Byrne
040e490f02 fix: update share dialog acknowledgement copy (#13044)
*PR Created by the Glary-Bot Agent*

---

Update the English copy on the share dialog acknowledgement checkbox to
better reflect the link-based visibility model.

**Before:** "I understand these media items will be published and made
public"
**After:** "I understand anyone with the link can view these files"

Only the `en` locale string was changed
(`shareWorkflow.acknowledgeCheckbox` in `src/locales/en/main.json`), per
the request. Other locale files are intentionally left to the standard
translation sync process.

The component (`ShareAssetWarningBox.vue`) reads this string via
`$t('shareWorkflow.acknowledgeCheckbox')`, so no component change is
needed.

## Verification
- `ShareAssetWarningBox.test.ts` — 12 passed (tests use their own mock
string and are unaffected).
- `ShareWorkflowDialogContent.test.ts` — 19 passed.
- Lint-staged hooks (oxfmt, oxlint, eslint, typecheck) ran on commit and
passed.
- Visual verification: rendered the new copy in a representative
checkbox layout via Playwright and confirmed the user-facing text
matches (screenshot attached).

## Screenshots

![Share dialog acknowledgement checkbox showing new copy: 'I understand
anyone with the link can view these
files'](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/2c2cd89e57ce203d296953b0c02b0d05e0d065a22629e974bc336f890fa85d2e/pr-images/1781924023274-711d37d8-f31f-4c42-bd00-bc37ae83b3ed.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-20 03:31:40 +00:00
Dante
90c523b4a3 feat(dialog): flip remaining callers + default renderer to Reka (Phase 6a cutover, stacked on #12848. 6a-2) (#12593)
## Summary

The **renderer cutover** for Phase 6: every remaining dialog caller is
flipped to Reka, and `createDialog` now defaults `renderer: 'reka'` so
the PrimeVue `Dialog` branch is no longer reached by default (it
survives only as an explicit `renderer: 'primevue'` escape hatch,
deleted in Phase 6b).

> **Stacked on #12848** (mask editor + 3D viewer dialogs + dialog
infra). Per @jtydhr88's review, the heavy, screenshot-bearing surface
(3D + mask editor) was split into #12848 so it reviews and tests on its
own. **Merge #12848 first**, then this PR's base auto-retargets to
`main`.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-578](https://linear.app/comfyorg/issue/FE-578/phase-6-remove-primevue-dialogconfirmdialog-imports-clean-up-css)

## Changes

- **drop dead `ConfirmationService` registration** — zero
`useConfirm`/`<ConfirmDialog>` consumers remain in `src/`; desktop-ui
keeps its own.
- **flip `showConfirmDialog`** — all six confirm callers render Reka
chrome; width goes from PrimeVue auto-hug to fixed `size:'md'`, matching
`dialogService.confirm()`.
- **flip remaining `dialogService` + composable callers** — signin,
update-password, top-up, workspace family, cancel-subscription, publish,
cloud-notification, edit-keybinding / node-conflict / import-failed,
upload-model, queue-clear-history, delete-assets, share /
open-shared-workflow, subscription pricing. Self-styled panels get a
shared transparent `w-fit` chrome replicating PrimeVue's auto-sized
root.
- **default `createDialog` to `renderer:'reka'`** — cuts over
`showExtensionDialog` (third-party dialogs) and anything unflagged. The
single-commit revert point.
- **retarget class-based e2e selectors** — `BaseDialog` `.p-dialog` →
`getByRole('dialog')`, `BuilderSaveAsHelper` close-X → `getByLabel`,
`shareWorkflowDialog` role-based, dead `confirm-dialog` testid removed.
- honor `[autofocus]` inside Reka dialogs; size the template browser
dialog so the filter bar fits; drop redundant Tailwind width constraints
on the remaining callers.

## Review focus

1. **`modal:false` on the pricing dialogs** — same trade-off as
Settings/Manager (visual overlay without focus trap) because
`PricingTable(.Workspace)` hosts a body-teleported PrimeVue `Popover`.
2. **`w-fit` shrink-wrapped chrome** for self-styled panels — replicates
PrimeVue's shrink-to-fit root.
3. **Confirm width change** (auto-hug → fixed 576px `md`) — intentional
consistency with `dialogService.confirm()`.

## Public API impact

`createDialog` now defaults to Reka. Third-party extension dialogs
render through Reka by default — a fixed `size:'md'` frame with a modal
focus trap instead of PrimeVue auto-width; `renderer:'primevue'` remains
an explicit escape hatch until Phase 6b. Worth a release note for
extension authors.

## Out of scope (Phase 6b)

PrimeVue branch deletion (`GlobalDialog.vue` legacy branch,
`PrimeDialog` import, `.p-dialog` CSS/bridge tokens, `dialogStore`
`pt`/`position`/`unstyled` typing) — lands after this soaks one cloud
deploy cycle.

## 📸 Screenshots — manual verification

Captured via Chrome DevTools (CDP) from this branch running locally in
**cloud mode** (proxied to the `cloud.comfy.org` backend, free Personal
Workspace). Every dialog below now renders through the **Reka** path —
the PrimeVue `Dialog` branch is no longer reached. (Mask editor + 3D
viewers live in the stacked base #12848.)

**Confirm dialog** (`showConfirmDialog`) — Reka chrome at a fixed
`size:'md'`, replacing PrimeVue's auto-hug width — *review focus #3*
<img width="880" alt="confirm-dialog"
src="https://github.com/user-attachments/assets/5d9953c1-4d0c-4ff9-adc7-88dd370c6a24"
/>

**Settings** — renders through Reka
<img width="880" alt="settings"
src="https://github.com/user-attachments/assets/44e3fd3f-8d9b-4322-8fbe-8ce8d94ed15d"
/>

**Edit Keybinding**, stacked on Settings — small-layout `w-fit` chrome;
closing it leaves Settings open (stacked-dismiss holds)
<img width="880" alt="edit-keybinding-nested"
src="https://github.com/user-attachments/assets/d0875c00-7b9c-439d-b24d-ba6770009d08"
/>

**Subscription pricing** (`PricingTable`) — opened with `modal:false`
because it hosts a body-teleported PrimeVue `Popover` — *review focus
#1*
<img width="880" alt="subscription-pricing"
src="https://github.com/user-attachments/assets/3be20397-8a69-4b00-b803-73eff4e0e313"
/>

**Share** and **Publish** (open-shared-workflow + publish) — shared
transparent shrink-wrapped (`w-fit`) chrome — *review focus #2*
<img width="880" alt="share-dialog"
src="https://github.com/user-attachments/assets/16f1c1b5-e35e-4664-a957-2f7f61ad96bd"
/>
<img width="880" alt="publish-dialog"
src="https://github.com/user-attachments/assets/935ff453-5247-430f-9c21-2f500d4bc6e2"
/>

**Workspace** (workspace-family callers)
<img width="880" alt="workspace-settings"
src="https://github.com/user-attachments/assets/8031a352-f6fc-41e4-9567-e26e0c35ecd9"
/>

**Template selector** (`showExtensionDialog` /
`useWorkflowTemplateSelectorDialog`)
<img width="880" alt="templates-dialog"
src="https://github.com/user-attachments/assets/9975ebbe-75ae-4ad9-a90a-248db4850e1a"
/>

**Account / workspace menu** (cloud)
<img width="880" alt="account-menu"
src="https://github.com/user-attachments/assets/5bc0cade-9bd9-49de-8bb4-779d65e211b0"
/>
2026-06-20 00:12:01 +00:00
Dante
1f759a758c feat(workspace): creator-only canManageSubscriptionLifecycle permission (FE-770) (#12829)
## What

Adds a creator-only `canManageSubscriptionLifecycle` permission (cancel
/ reactivate / downgrade) to `useWorkspaceUI`:

- Any workspace **owner** keeps `canManageSubscription` (manage payment
/ top-up / change-commit).
- Only the **original owner (creator)** also gets
`canManageSubscriptionLifecycle`.
- **Personal** workspaces (single-member) always get it; **members**
never do.

Driven by an `is_creator` flag on the workspace from `/api/workspaces`,
plumbed through the shared type and the auth/session Zod schema.

## Safe to merge ahead of BE / no regression

- **Pure infra — nothing reads the permission yet.** Consumers land
separately: #12786 (cancel/reactivate), #12789 (downgrade). No code on
`main` references `canManageSubscriptionLifecycle` or `is_creator`.
- **Additive & fails closed.** `is_creator` is optional; when absent the
permission is `false`. Existing permission values are unchanged, so
existing consumers (`SubscribeToRun`,
`SubscriptionPanelContentWorkspace`, `CurrentUserPopoverWorkspace`, …)
behave identically.

## BE contract (confirmed with Hunter, 2026-06-17)

- `is_creator` = current-user-relative boolean on `/api/workspaces`; the
creator is tracked explicitly (not by creation date).
- Intentionally **temporary** — removed once member-removal
auto-provisions a personal workspace.
- The FE-770 members-panel creator-**row** lock additionally needs the
creator **id** (`created_by_user_id`) — separate, still-open ask.

## Follow-up (separate PR, once BE ships)

`is_creator` + `WorkspaceWithRoleSchema` are hand-rolled only because
the cloud ingest OpenAPI doesn't expose the field yet.
`@comfyorg/ingest-types` already generates `WorkspaceWithRole` +
`zWorkspaceWithRole`. Once BE adds `is_creator` to
`services/ingest/openapi.yaml`, regenerate and swap the hand-rolled
interface + schema for the generated ones (TODOs marked in
`workspaceApi.ts` / `workspaceAuthStore.ts`).
2026-06-19 23:41:36 +00:00
Matt Miller
44557fd138 fix(asset-card): read image dimensions from typed metadata field (#12328)
## ELI-5

The asset library shows each image's resolution (e.g. "1920×1080") on
its card. It used to get that by measuring the picture the browser
actually drew — fine locally, but on cloud the card draws a small
**downscaled thumbnail**, so it measured the thumbnail and showed the
wrong size (like "512×288") instead of the real one.

Now the card prefers the **true dimensions the server records** for each
image. When the server hasn't recorded them yet (an old image not
backfilled, or something unmeasurable like an SVG), it only falls back
to measuring the drawn image when that image is the original — never a
downscaled thumbnail. If all it can measure is a thumbnail, it shows
**no size rather than a wrong one**: a blank beats a confidently wrong
number.

## Summary

Asset cards render image dimensions from the asset response's typed
`metadata` field (`asset.metadata.width` / `asset.metadata.height`) when
available, falling back to the locally-measured `naturalWidth` /
`naturalHeight` of the rendered `<img>` — but only when the rendered
image was the original, not a downscaled thumbnail.

## Why

`MediaAssetCard.vue` previously read dimensions from the rendered
`<img>`'s `naturalWidth` / `naturalHeight` — correct only when the
runtime serves the original file. Runtimes that serve a downscaled
preview return preview-sized dimensions, so the label could surface the
preview's size rather than the source asset's. Reading the typed
`metadata` field when present fixes that. And when metadata is absent,
the natural-size fallback is now **suppressed if a downscaled thumbnail
was rendered** (`asset.thumbnail_url` present), so the card shows no
dimensions rather than a misleading thumbnail size.

## What changes

- `assetMetadataUtils.ts`: new pure
`resolveDisplayImageDimensions(asset, renderedNaturalSize)` — prefers
server `metadata.{width,height}`, falls back to the rendered natural
size only when no thumbnail was rendered, otherwise returns `undefined`
(no label). Adds an exported `ImageDimensions` type.
- `MediaAssetCard.vue`: `displayImageDimensions` delegates to the
helper; `metaInfo` reads from it and does not branch on runtime
identity.
- `assetMetadataUtils.test.ts`: unit tests for
`resolveDisplayImageDimensions` across every branch (metadata present,
metadata-wins-over-thumbnail, no-thumbnail fallback, thumbnail-guard
suppression, invalid shape, both-absent, undefined asset).

## Compatibility

- **metadata has numeric `width`/`height`** → card displays the typed
(true) values.
- **metadata absent, no thumbnail rendered (original served)** →
unchanged: displays the locally-measured dimensions.
- **metadata absent, downscaled thumbnail rendered** → displays **no**
dimensions. This is the one intentional behavior change: previously the
card showed the thumbnail's size (a wrong value); now it shows a blank
until the real dimensions are available (e.g. after the cloud dimension
backfill), and stays blank for permanently-unmeasurable assets
(SVG/corrupt). Blank instead of wrong.

## Test plan

- [x] `pnpm test:unit` — `resolveDisplayImageDimensions` covered across
all branches (70 tests pass)
- [x] `oxfmt --check` + `oxlint --type-aware` clean on touched files
- [ ] `pnpm typecheck` (vue-tsc) — via CI
- [ ] Manual: asset library — image cards show real resolution on cloud;
un-backfilled / SVG cards show no size label (not a thumbnail size);
non-image cards (video / audio / 3D) still render their size label

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12328-refactor-asset-card-read-image-dimensions-from-typed-metadata-field-3656d73d3650811bb0cff00b6bc2d2e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Matt Miller <mattmillerai@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-19 23:30:16 +00:00
Christian Byrne
90210292d7 fix: add explicit return types for composables to fix TS declaration errors (#13034)
## Summary
- Fix TS2742 and TS4060 errors during .d.ts generation in the types
build
- Add explicit interface definitions and return type annotations for
composables

## Changes
- `confirmDialog.ts`: explicit `DialogInstance` return type
- `useImageCrop.ts`: export `ResizeHandle` interface
- `useAbsolutePosition.ts`: `UseAbsolutePositionReturn` interface
- `useDomClipping.ts`: `UseDomClippingReturn` interface
- `useNodePreviewAndDrag.ts`: `UseNodePreviewAndDragReturn` interface
- `i18n.ts`: explicit type annotations for exported functions

## Test plan
- [ ] Types build succeeds: `pnpm build:types`
- [ ] No new lint errors: `pnpm lint`
- [ ] Type check passes: `pnpm typecheck`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-19 16:34:22 -07:00
57 changed files with 2078 additions and 267 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import { initializeApp } from 'firebase/app'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
@@ -133,7 +132,6 @@ app
}
}
})
.use(ConfirmationService)
.use(ToastService)
.use(pinia)
.use(i18n)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -258,15 +258,15 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures events with metadata', async () => {
it('captures auth events with metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ method: 'google' })
provider.trackAuth({ method: 'google', share_id: 'share-1' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{ method: 'google' }
{ method: 'google', share_id: 'share-1' }
)
})

View File

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

View File

@@ -266,11 +266,7 @@ describe('useSharedWorkflowUrlLoader', () => {
view_mode: 'graph',
is_app_mode: false
})
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
'share_auth',
{ share: 'share-id-1' },
['share']
)
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'share'

View File

@@ -9,13 +9,13 @@ import { useTelemetry } from '@/platform/telemetry'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
import {
capturePreservedQuery,
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { isValidShareId } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -49,10 +49,6 @@ export function useSharedWorkflowUrlLoader() {
const { mode, isAppMode } = useAppMode()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
function isValidParameter(param: string): boolean {
return /^[a-zA-Z0-9_.-]+$/.test(param)
}
async function ensureShareQueryFromIntent() {
hydratePreservedQuery(SHARE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
@@ -110,11 +106,7 @@ export function useSharedWorkflowUrlLoader() {
},
dialogComponentProps: {
onClose: () => resolve({ action: 'cancel' }),
pt: {
root: {
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
}
}
contentClass: 'sm:max-w-176 rounded-2xl overflow-hidden'
}
})
})
@@ -133,7 +125,7 @@ export function useSharedWorkflowUrlLoader() {
return 'not-present'
}
if (!isValidParameter(shareParam)) {
if (!isValidShareId(shareParam)) {
console.warn(
`[useSharedWorkflowUrlLoader] Invalid share parameter format: ${shareParam}`
)
@@ -152,13 +144,6 @@ export function useSharedWorkflowUrlLoader() {
view_mode: mode.value,
is_app_mode: isAppMode.value
})
if (!isLoggedIn.value) {
capturePreservedQuery(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
{ share: shareParam },
['share']
)
}
const result = await showOpenSharedWorkflowDialog(shareParam)

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { LocationQuery } from 'vue-router'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import {
isValidShareId,
preserveLoggedOutShareAuthAttribution
} from './shareAuthAttribution'
const SHARE_AUTH_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE_AUTH
const invalidShareQueries: Array<{ name: string; query: LocationQuery }> = [
{ name: 'missing', query: {} },
{ name: 'array value', query: { share: ['share-id-1'] } },
{ name: 'invalid characters', query: { share: '../share-id-1' } }
]
describe('shareAuthAttribution', () => {
beforeEach(() => {
clearPreservedQuery(SHARE_AUTH_NAMESPACE)
sessionStorage.clear()
})
it('preserves a valid share id for logged-out users', () => {
preserveLoggedOutShareAuthAttribution({ share: 'share-id_1.2' }, false)
expect(getPreservedQueryParam(SHARE_AUTH_NAMESPACE, 'share')).toBe(
'share-id_1.2'
)
expect(sessionStorage.getItem('Comfy.PreservedQuery.share_auth')).toContain(
'share-id_1.2'
)
})
it('does not preserve share attribution for logged-in users', () => {
preserveLoggedOutShareAuthAttribution({ share: 'share-id-1' }, true)
expect(
getPreservedQueryParam(SHARE_AUTH_NAMESPACE, 'share')
).toBeUndefined()
expect(sessionStorage.getItem('Comfy.PreservedQuery.share_auth')).toBeNull()
})
it.for(invalidShareQueries)(
'does not preserve $name share params',
({ query }) => {
preserveLoggedOutShareAuthAttribution(query, false)
expect(
getPreservedQueryParam(SHARE_AUTH_NAMESPACE, 'share')
).toBeUndefined()
expect(
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
).toBeNull()
}
)
it.for([
{ shareId: 'abc123', expected: true },
{ shareId: 'share-id_1.2', expected: true },
{ shareId: '../share-id-1', expected: false },
{ shareId: '', expected: false }
])('validates "$shareId" as $expected', ({ shareId, expected }) => {
expect(isValidShareId(shareId)).toBe(expected)
})
})

View File

@@ -0,0 +1,26 @@
import type { LocationQuery } from 'vue-router'
import { capturePreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
const SHARE_QUERY_KEY = 'share'
export function isValidShareId(shareId: string): boolean {
return /^[a-zA-Z0-9_.-]+$/.test(shareId)
}
export function preserveLoggedOutShareAuthAttribution(
query: LocationQuery,
isLoggedIn: boolean
): void {
if (isLoggedIn) return
const shareId = query[SHARE_QUERY_KEY]
if (typeof shareId !== 'string' || !isValidShareId(shareId)) return
capturePreservedQuery(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
{ [SHARE_QUERY_KEY]: shareId },
[SHARE_QUERY_KEY]
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
const cloudOnboardingRoutes = isCloud
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
@@ -169,6 +170,7 @@ if (isCloud) {
// Pass authenticated users
const authHeader = await authStore.getAuthHeader()
const isLoggedIn = !!authHeader
preserveLoggedOutShareAuthAttribution(to.query, isLoggedIn)
// Allow public routes
if (isPublicRoute(to)) {

View File

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

View File

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

View File

@@ -6,7 +6,10 @@ import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import {
capturePreservedQuery,
clearPreservedQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
@@ -687,31 +690,104 @@ describe('useAuthStore', () => {
)
}
)
})
})
it('includes preserved share id on new-user social auth', async () => {
sessionStorage.setItem(
'Comfy.PreservedQuery.share_auth',
JSON.stringify({ share: 'share-1' })
)
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: true,
providerId: 'google.com',
profile: null
})
describe('share auth attribution', () => {
const mockUserCredential = {
user: mockUser
} as Partial<UserCredential> as UserCredential
await store.loginWithGoogle()
const preserveShareAuth = () => {
capturePreservedQuery(
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
{ share: 'share-1' },
['share']
)
}
expect(mockTrackAuth).toHaveBeenCalledWith(
expect.objectContaining({
is_new_user: true,
share_id: 'share-1'
})
)
expect(
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
).toBeNull()
const expectShareAuthConsumed = () => {
expect(
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
).toBeNull()
}
beforeEach(() => {
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential
)
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
mockUserCredential
)
vi.mocked(firebaseAuth.signInWithPopup).mockResolvedValue(
mockUserCredential
)
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
isNewUser: true,
providerId: 'google.com',
profile: null
})
})
it('includes share_id on email signup auth completion', async () => {
preserveShareAuth()
await store.register('new@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'email',
is_new_user: true,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
it('includes share_id on email login auth completion', async () => {
preserveShareAuth()
await store.login('test@example.com', 'password')
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'email',
is_new_user: false,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
it('includes share_id on Google auth completion', async () => {
preserveShareAuth()
await store.loginWithGoogle()
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'google',
is_new_user: true,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
it('includes share_id on GitHub auth completion', async () => {
preserveShareAuth()
await store.loginWithGithub()
expect(mockTrackAuth).toHaveBeenCalledWith({
method: 'github',
is_new_user: true,
user_id: 'test-user-id',
email: 'test@example.com',
share_id: 'share-1'
})
expectShareAuthConsumed()
})
})
describe('accessBillingPortal', () => {

View File

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

View File

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

View File

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