Compare commits

..

63 Commits

Author SHA1 Message Date
Alexander Brown
8f2736095c refactor: move node definition loading from bootstrapStore to nodeDefStore
- Extract loadNodeDefs action to nodeDefStore for better separation of concerns
- Add initializeFromBackend action to nodeDefStore that handles full initialization
- Simplify bootstrapStore by delegating node def loading to nodeDefStore
- Update app.ts to use new nodeDefStore.initializeFromBackend method
- Expand test coverage for both stores

Amp-Thread-ID: https://ampcode.com/threads/T-019bfccc-e67e-739e-965f-d33c913371ef
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 17:07:33 -08:00
Alexander Brown
16b285a79c Remove the early bootstrap node def loading for now. 2026-01-26 16:57:41 -08:00
Alexander Brown
35eb0286e5 refactor: move workflow loading state from bootstrapStore to workflowStore
- Add useAsyncState in workflowStore for syncWorkflows with loading state

- Add loadWorkflows action that guards against double-loading

- Simplify bootstrapStore by delegating workflow loading to workflowStore

- Update test mocks for workflowStore

Amp-Thread-ID: https://ampcode.com/threads/T-019bfcce-cce0-70b0-abc6-742669ac86e3
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 16:44:52 -08:00
Alexander Brown
c171227d28 refactor: move settings loading state to settingStore
- Move isReady, isLoading, error state from bootstrapStore to settingStore
- Use useAsyncState in settingStore for load() action
- Rename loadSettingValues to load for consistency
- Update GraphCanvas to get settings state from settingStore
- Update tests to use createTestingPinia with stubActions: false

Amp-Thread-ID: https://ampcode.com/threads/T-019bfc95-aaa8-737d-bcd2-a5bdbc8b158f
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 15:16:49 -08:00
Alexander Brown
51bce9239e fix: use existing store references in useEventListener
Amp-Thread-ID: https://ampcode.com/threads/T-019bfb9a-f027-75d8-9859-26428d3025eb
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 10:40:55 -08:00
Alexander Brown
52483b34ed fix: improve locale change watcher to wait for settings and i18n in parallel
- Remove redundant useVueFeatureFlags() call
- Wait for settings and i18n readiness in parallel instead of sequentially
- Add warning log when locale changes during error state

Amp-Thread-ID: https://ampcode.com/threads/T-019bfb8f-8b22-740f-a77d-9280bcc44be8
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 10:29:20 -08:00
Alexander Brown
80d58ddc8b refactor: move composables and watchers outside onMounted
- Move useCopy, usePaste, useWorkflowAutoSave to top level

- Move locale change watcher outside onMounted with proper guard

- Move litegraph:set-graph event listener to top level

Amp-Thread-ID: https://ampcode.com/threads/T-019bf94d-b905-77ee-965d-a9c2a327dc2a
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 23:58:22 -08:00
Alexander Brown
3c0b227f94 refactor: consolidate workspace spinner and shiftDown state
- Replace manual keydown/keyup listeners with useMagicKeys for shiftDown

- Move app.extensionManager assignment to App.vue

- Remove init() wrapper function in GraphView.vue

Amp-Thread-ID: https://ampcode.com/threads/T-019bf90e-1cd0-71de-b23f-ea6cb0f99a87
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 22:48:48 -08:00
Alexander Brown
0a73c5a131 refactor: simplify GraphView event listeners with VueUse
Amp-Thread-ID: https://ampcode.com/threads/T-019bf491-17c4-75d1-a218-85842c463232
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 01:57:23 -08:00
Alexander Brown
326154f2b2 refactor: extract early bootstrap logic to bootstrapStore
- Add useBootstrapStore to centralize early initialization (api.init, fetchNodeDefs)
- Move settings loading and custom nodes i18n loading to store bootstrap phase
- Use VueUse's `until` to coordinate async dependencies in GraphCanvas
- Load settings, i18n, and newUserService initialization in parallel where possible
- Add unit tests for bootstrapStore

Amp-Thread-ID: https://ampcode.com/threads/T-019bf48d-af90-738f-99ce-46309e4be688
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 01:48:34 -08:00
Alexander Brown
33c457aca0 Assorted fixes for issues that happen when routing instead of reloading 2026-01-25 01:05:20 -08:00
Alexander Brown
8afb45db9c AGENTS changes 2026-01-25 01:03:33 -08:00
Alexander Brown
702c917e57 feat: add getAssetFilename util with fallback chain (#8309)
## Summary

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8309-feat-add-getAssetFilename-util-with-fallback-chain-2f36d73d36508141be81ecc52c0a2858)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-24 17:50:00 -08:00
Alexander Brown
7b0830a4ca fix: fallback to asset metadata/name when filename missing (#8302)
## Summary

Fix model node creation failing when `user_metadata.filename` is missing
by falling back to `asset.metadata.filename` or `asset.name`.

## Changes

- Add fallback chain for filename: `userMetadata.filename ||
validAsset.metadata?.filename || validAsset.name`

## Testing

Manual testing with assets that have filename in different metadata
locations.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8302-fix-fallback-to-asset-metadata-name-when-filename-missing-2f36d73d365081478299e2f2c1abde81)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 00:27:42 +00:00
Simula_r
4771565486 Workspaces 4 members invites (#8245)
## Summary

  Add team workspace member management and invite system.

## Changes

- Add members panel with role management (owner/admin/member) and member
removal
- Add invite system with email invites, pending invite display, and
revoke functionality
   - Add invite URL loading for accepting invites
  - Add subscription panel updates for member management
  - Add i18n translations for member and invite features

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8245-Workspaces-4-members-invites-2f06d73d36508176b2caf852a1505c4a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-24 15:52:40 -08:00
Benjamin Lu
aa6f9b7009 fix: persist assets sidebar view mode (#8299) 2026-01-24 15:17:34 -08:00
Alexander Brown
133427b08e Fix: Background Image fix for dark theme loading change. (#8292)
## Summary

The variable whose name implied it was just an image URL bundled other
background pieces.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8292-Fix-Background-Image-fix-for-dark-theme-loading-change-2f26d73d36508177ae7bc24ae3cb027b)
by [Unito](https://www.unito.io)
2026-01-24 15:03:51 -08:00
Alexander Brown
e8022f9dee Style: Dark mode body on load alternative (#8287)
## Summary

Alternative to https://github.com/Comfy-Org/ComfyUI_frontend/pull/8077

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8287-Style-Dark-mode-body-on-load-alternative-2f26d73d365081fb8231f167e75beb83)
by [Unito](https://www.unito.io)
2026-01-23 22:23:10 -08:00
AustinMroz
3bfd62b9fc Linear: progressbar, tooltips, and output fixes (#8250)
- Fixes only the first output being displayed in linear mode after the
jobs migration
- Fixes selected output no longer scrolling into view in history
- Adds a progress bar indicator on running job
<img width="113" height="102" alt="image"
src="https://github.com/user-attachments/assets/ca684dbe-12c8-44aa-98f0-2985c0159156"
/>
- Moves linear toggle button to v-tooltip
- Fixes placeholder sometimes continuing to display after a new output.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8250-Linear-progressbar-tooltips-and-output-fixes-2f06d73d365081ca9fa3ebf0e2516487)
by [Unito](https://www.unito.io)
2026-01-23 21:08:31 -08:00
Alexander Brown
15655ddb76 Updates: More Modal Modification (#8256)
Refactors modal dialog layouts for improved flexibility and consistency.

**Changes:**
- Add dedicated slot for left panel header title with dynamic
content/icons
- Consolidate side panel rendering within `BaseModalLayout`
- Remove redundant `PanelHeader` and `RightSidePanel` components
- Apply `select-none` to text elements to prevent accidental selection

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-23 20:41:35 -08:00
729_GM
e5583fe955 docs(locale zh): confused with the original EmptyImageLatent, two identical options are displayed on the interface but they aren't the same. (#8273)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8273-docs-locale-zh-confused-with-the-original-EmptyImageLatent-two-identical-options-are-d-2f16d73d3650811b9a01dcfd058c1348)
by [Unito](https://www.unito.io)
2026-01-24 05:11:50 +01:00
Johnpaul Chiwetelu
b1d8bf0b13 refactor: eliminate unsafe type assertions from Group 2 test files (#8258)
## Summary
Improved type safety in test files by eliminating unsafe type assertions
and adopting official testing patterns. Reduced unsafe `as unknown as`
type assertions and eliminated all `null!` assertions.

## Changes
- **Adopted @pinia/testing patterns**
- Replaced manual Pinia store mocking with `createTestingPinia()` in
`useSelectionState.test.ts`
  - Eliminated ~120 lines of mock boilerplate
- Created `createMockSettingStore()` helper to replace duplicated store
mocks in `useCoreCommands.test.ts`

- **Eliminated unsafe null assertions**
- Created explicit `MockMaskEditorStore` interface with proper nullable
types in `useCanvasTools.test.ts`
- Replaced `null!` initializations with `null` and used `!` at point of
use or `?.` for optional chaining

- **Made partial mock intent explicit**
- Updated test utilities in `litegraphTestUtils.ts` to use explicit
`Partial<T>` typing
- Changed cast pattern from `as T` to `as Partial<T> as T` to show
incomplete mock intent
- Applied to `createMockLGraphNode()`, `createMockPositionable()`, and
`createMockLGraphGroup()`

- **Created centralized mock utilities** in
`src/utils/__tests__/litegraphTestUtils.ts`
- `createMockLGraphNode()`, `createMockPositionable()`,
`createMockLGraphGroup()`, `createMockSubgraphNode()`
  - Updated 8+ test files to use centralized utilities
- Used union types `Partial<T> | Record<string, unknown>` for flexible
mock creation

## Results
-  0 typecheck errors
-  0 lint errors  
-  All tests passing in modified files
-  Eliminated all `null!` assertions
-  Reduced unsafe double-cast patterns significantly

## Files Modified (18)
- `src/components/graph/SelectionToolbox.test.ts`
-
`src/components/graph/selectionToolbox/{BypassButton,ColorPickerButton,ExecuteButton}.test.ts`
- `src/components/sidebar/tabs/queue/ResultGallery.test.ts`
- `src/composables/canvas/useSelectedLiteGraphItems.test.ts`
- `src/composables/graph/{useGraphHierarchy,useSelectionState}.test.ts`
-
`src/composables/maskeditor/{useCanvasHistory,useCanvasManager,useCanvasTools,useCanvasTransform}.test.ts`
- `src/composables/node/{useNodePricing,useWatchWidget}.test.ts`
- `src/composables/{useBrowserTabTitle,useCoreCommands}.test.ts`
- `src/utils/__tests__/litegraphTestUtils.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8258-refactor-eliminate-unsafe-type-assertions-from-Group-2-test-files-2f16d73d365081549c65fd546cc7c765)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-01-24 05:10:35 +01:00
Rizumu Ayaka
6b6b467e68 feat: implement fuzzy search for widgets and nodes using Fuse in Right Side Panel (#8043)
related
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7812#discussion_r2685117810

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8043-feat-implement-fuzzy-search-for-widgets-and-nodes-using-Fuse-in-Right-Side-Panel-2e86d73d365081d7869cfa1956dfc0ad)
by [Unito](https://www.unito.io)
2026-01-23 20:43:09 -07:00
AustinMroz
ef2d34c560 Add 3d control buttons to linear mode (#8178)
Adds control buttons to the top left of the 3d preview in linear mode.
<img width="460" alt="image"
src="https://github.com/user-attachments/assets/35a83b9c-65af-46c3-a910-be5ad30c428e"
/>


This was deprioritized because I forgot the secret to magically
unwrapping a set of refs (wrap them in another ref).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8178-Add-3d-control-buttons-to-linear-mode-2ee6d73d3650816ab1a8e73ace1bdbc7)
by [Unito](https://www.unito.io)
2026-01-23 20:24:48 -07:00
Rizumu Ayaka
1b1356951e feat: add settings option to always show advanced widgets on all nodes (#8244)
Solved issue:
Currently, the display status of advanced widgets can only be set
individually for each node, but users would like to have a global switch
to always display all advanced widgets.

I also adjusted some related code to solve the issue of code
duplication.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8244-feat-add-settings-option-to-always-show-advanced-widgets-on-all-nodes-2f06d73d365081358023efa3e1ff3094)
by [Unito](https://www.unito.io)
2026-01-23 19:47:51 -07:00
Rizumu Ayaka
3e9a390c25 fix: letter sorting in image dropdown (#8277)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8277-fix-letter-sorting-in-image-dropdown-2f16d73d3650818ea82dff944d345ec7)
by [Unito](https://www.unito.io)
2026-01-23 19:30:20 -07:00
Benjamin Lu
7c61dadaf2 Replace QPO with opening assets tab (#8260)
Route the queue progress button to toggle the Assets sidebar when QPO V2
is enabled.

This effectively "removes" the QPO for users with QPOV2 enabled.


https://github.com/user-attachments/assets/fa76482d-2dc7-4c28-8810-c15c338c51e4

---------

Co-authored-by: Benjamin Lu <ben@Mac.lan>
2026-01-23 18:52:06 -07:00
Alexander Brown
7246ec7f1c Templates: Search speed (#8286)
## Summary

...

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8286-Templates-Search-speed-2f26d73d365081dab3d1cccd7878a1de)
by [Unito](https://www.unito.io)
2026-01-23 16:57:55 -08:00
Christian Byrne
2d0980cb1c fix: use authenticated API for remote config polling (#8266)
## Summary
- Fixes remote config polling to use authenticated API
- Consolidates `loadRemoteConfig` into `refreshRemoteConfig` with auth
control
- Adds unit tests for both auth modes

## Problem
The cloud extension's polling interval was using unauthenticated
`fetch`, causing it to receive only default feature flags instead of
user-specific configurations.

**Root cause:**
1. Bootstrap called `loadRemoteConfig()` (raw `fetch`, no auth) -
correct, auth not initialized yet
2. Extension watch called `refreshRemoteConfig()` (`api.fetchApi`, with
auth) - correct
3. Extension interval called `loadRemoteConfig()` (raw `fetch`, no auth)
- **bug**

## Solution
- Consolidate into single `refreshRemoteConfig()` with optional
`useAuth` parameter (defaults to `true`)
- Bootstrap: `refreshRemoteConfig({ useAuth: false })`
- Polling: `refreshRemoteConfig()` (authenticated by default)

## Test Plan
- Unit tests verify both auth modes
- `pnpm typecheck`, `pnpm lint`, `pnpm test:unit` all pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8266-fix-use-authenticated-API-for-remote-config-polling-2f16d73d3650817ea7b0e3a7e3ccf12a)
by [Unito](https://www.unito.io)
2026-01-23 15:41:21 -08:00
Terry Jia
d9e1122677 fix: replace vite preload error reload with error logging (#8261)
## Summary
The reload approach didn't fully work because CSS and other preload
errors emit different error types. Log errors for Sentry tracking
instead, to be solved on the backend by serving chunks from past
deployments.

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/8153

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8261-fix-replace-vite-preload-error-reload-with-error-logging-2f16d73d365081e3b309f5470412506a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-23 15:16:13 -08:00
Comfy Org PR Bot
69b57eaba5 1.38.10 (#8254)
Patch version increment to 1.38.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8254-1-38-10-2f16d73d36508183a716cc1ad72e2819)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-23 15:00:19 -08:00
Jin Yi
f647c8f9ee [bugfix] Fix inconsistent menu icon sizes in ComfyMenuButton (#8268)
## Summary
Replace MDI font icons with Tailwind Iconify (Lucide) icons for Settings
and Manage Extensions menu items to fix size inconsistency with Browse
Template icon.

## Changes
- **What**: MDI font icons (`mdi mdi-cog-outline`, `mdi
mdi-puzzle-outline`) rendered at 14px (`text-sm` font-size), while the
Tailwind Iconify icon (`icon-[comfy--template]`) rendered at ~16.8px due
to `scale: 1.2` in the Tailwind plugin config. Replaced with
`icon-[lucide--settings]` and `icon-[lucide--puzzle]` so all icons use
the same rendering pipeline.
2026-01-23 15:47:50 +09:00
AustinMroz
7952eb477e Add telemetry for entering linear mode (#8263)
Standard disclaimer: Telemetry only applies on cloud builds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8263-Add-telemetry-for-entering-linear-mode-2f16d73d3650819ea53efeeb562ea095)
by [Unito](https://www.unito.io)
2026-01-22 22:28:41 -07:00
Jin Yi
df85c4d463 [refactor] Move ActiveJobCard to platform/assets and add ActiveMediaAssetCard story (#8242) 2026-01-23 13:49:33 +09:00
Benjamin Lu
6bbea48d8e feat: active jobs context menu (#8216)
Add a right-click context menu to the active jobs button that clears the
queue and matches the Queue Progress modal styling.

Per
[design](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3407-41345&m=dev)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8216-feat-active-jobs-context-menu-2ef6d73d365081e68386cf0f7c3c23f2)
by [Unito](https://www.unito.io)
2026-01-22 18:46:21 -08:00
Christian Byrne
219d86db2c feat: Add search_aliases to node search (#8223)
Adds `search_aliases` to Fuse.js search keys, enabling users to find
nodes by alternative names.



https://github.com/user-attachments/assets/6bde3e5d-29c7-4cb0-b102-e600a92c7019



## Changes
- Add `search_aliases` to Fuse.js keys in `nodeSearchService.ts`
- Add type definition for `search_aliases` field in `nodeDefSchema.ts`

**Depends on:** Comfy-Org/ComfyUI#12010

## Related PRs
- **Backend:** Comfy-Org/ComfyUI#12010,
https://github.com/Comfy-Org/ComfyUI/pull/12035/
- **Adding aliases**:
  - https://github.com/Comfy-Org/ComfyUI/pull/12016
  - https://github.com/Comfy-Org/ComfyUI/pull/12017
  - https://github.com/Comfy-Org/ComfyUI/pull/12018
  - https://github.com/Comfy-Org/ComfyUI/pull/12019
- **Docs:** https://github.com/Comfy-Org/docs/pull/729

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8223-feat-Add-search_aliases-to-node-search-2ef6d73d365081d89bcccffb33659a88)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-22 19:06:05 -07:00
Johnpaul Chiwetelu
941cd2b4a5 refactor: improve TypeScript patterns in test files (Group 1/8) (#8253)
## Summary
Improves type safety in test files by replacing unsafe type patterns
with proper TypeScript idioms.

## Changes
- Define typed `TestWindow` interface extending `Window` for Playwright
tests with custom properties
- Use `Partial<HTMLElement>` with single type assertion for DOM element
mocks
- Remove redundant type imports
- Fix `console.log` → `console.warn` in test fixture

## Files Changed
16 test files across browser_tests, packages, and src/components

## Test Plan
-  `pnpm typecheck` passes
-  No new `any` types introduced
-  All pre-commit hooks pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8253-refactor-improve-TypeScript-patterns-in-test-files-Group-1-8-2f16d73d365081548f9ece7bcf0525ee)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-23 02:35:09 +01:00
AustinMroz
9efcbe682f Further number widget fixes (#8251)
- The slider indicator is now only rounded at the ends and doesn't
display outside the widget at small values
- Prevents a bug where scrubbing would result in a 1/10 chance of
causing text selection after a completed scrub.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/94d1a232-4667-4f99-8fce-93567a10b2f3"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/66a44109-906f-4c1e-809e-118c9c96eb4a"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8251-Further-number-widget-fixes-2f06d73d3650811f9548ded527ca16ae)
by [Unito](https://www.unito.io)
2026-01-22 16:29:17 -08:00
Alexander Brown
4b1a30e330 Updates: Model Management (#8248)
## Summary

Model management improvements: refactored tag API, enhanced UX for
trigger phrases and editing.

## Changes

### API Refactor
- Add `addAssetTags` (POST) and `removeAssetTags` (DELETE) endpoints in
assetService
- `updateAssetTags` now computes diff and calls remove/add serially
- Add `TagsOperationResult` schema; cache syncs with server response

### UX Improvements
- **Trigger phrases**: click to copy individual phrases, copy-all button
in header
- **Display name**: show edit icon on hover instead of relying on
double-click
- **Model type**: show plain text when immutable instead of disabled
select
- **Tags input**: only show edit icon on hover
- **Field labels**: updated styling to use muted foreground color
- **Optimistic update** for model type selection

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-23 00:10:54 +00:00
Johnpaul Chiwetelu
524c7e9b95 feat: enable feedback button on nightly releases (#8220)
## Summary

Enables the feedback button on nightly releases to collect user feedback
and distinguish it from stable releases in Zendesk.

## Changes

- Add distribution tracking to feedback URL (cloud/nightly/stable)
- Export `getDistribution()` and `ZENDESK_FIELDS` from support config
for reuse
- Enable feedback button for both cloud and nightly builds
- Track distribution in Zendesk as: `ccloud`, `oss-nightly`, or `oss`
- Fix type signatures for `normalizeIndustry`/`normalizeUseCase` to
accept `unknown`

## Requirements

- [ ] Support team needs to add `oss-nightly` as a valid value for the
distribution field in Zendesk

## Test plan

- [ ] Build and run nightly version, verify feedback button appears
- [ ] Click feedback button, verify Zendesk form opens with correct
distribution parameter
- [ ] Verify cloud builds still show feedback button as before
- [ ] Verify stable OSS builds don't show feedback button

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8220-feat-enable-feedback-button-on-nightly-releases-2ef6d73d3650816db81ef970919770a4)
by [Unito](https://www.unito.io)
2026-01-22 16:12:25 -07:00
Simula_r
a08ccb55c1 Workspaces 3 create a workspace (#8221)
## Summary

Add workspace creation and management (create, edit, delete, leave,
switch workspaces).

  Follow-up PR will add invite and membership flow.

  ## Changes

  - Workspace CRUD dialogs
  - Workspace switcher popover in topbar
  - Workspace settings panel
  - Subscription panel for workspace context
  

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8221-Workspaces-3-create-a-workspace-2ef6d73d36508155975ffa6e315971ec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 14:54:41 -07:00
Alexander Piskun
5c142275ad Move price badges to python nodes (#7816)
## Summary

Backend part: https://github.com/Comfy-Org/ComfyUI/pull/11582

- Move API node pricing definitions from hardcoded frontend functions to
backend-defined JSONata expressions
- Add `price_badge` field to node definition schema containing JSONata
expression and dependency declarations
- Implement async JSONata evaluation with signature-based caching for
efficient reactive updates
- Show one decimal in credit badges when meaningful (e.g., 1.5 credits
instead of 2 credits)

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7816-Move-price-badges-to-python-nodes-2da6d73d365081ec815ef61f7e3c65f7)
by [Unito](https://www.unito.io)
2026-01-22 09:17:07 -08:00
AustinMroz
4a5e7c8bcb Further dynamic input fixes (#8026)
- Fix deserialization of matchtype inputs spawned by autogrow.
- Rotate multitype slot indicators to align with design changes.
- Fix several instance of incorrect group matching
- MatchType reactively updates input type in vue
- Support the "hollow circle" optional input indicator in vue
- Custom combo sends index of selection to backend

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8026-Further-dynamic-input-fixes-2e76d73d3650819680fef327a94f4294)
by [Unito](https://www.unito.io)
2026-01-22 00:02:49 -08:00
Benjamin Lu
df93277802 refactor: use orderBy for queue list sorting (#8228)
Use es-toolkit orderBy for queue list sorting.

The queue overlay list already sorts by create time, but the
implementation used Array.sort with a custom comparator and mutated the
array in place. Switch to es-toolkit's orderBy to make the sort intent
explicit, avoid mutation, and align with the utility set we already
depend on. Sorting keys and direction remain the same, so behavior is
unchanged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8228-fix-use-orderBy-for-queue-list-sorting-2f06d73d365081e791fff7d2212537f8)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-21 20:21:39 -08:00
Comfy Org PR Bot
db16cfbb2b 1.38.9 (#8226)
Patch version increment to 1.38.9

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8226-1-38-9-2f06d73d3650817d9770f965c3497200)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-21 20:11:03 -08:00
Benjamin Lu
92357445e4 feat: show active jobs label in top menu (#8169)
Replace the top-menu queue history icon with a localized “N active”
label so active jobs are visible at a glance.

Requested as part of the new
[designs](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3381-6181&m=dev).

I checked all failing snapshots and they are all expected (1 flaky).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8169-feat-show-active-jobs-label-in-top-menu-2ee6d73d3650812cbf0cda389395c563)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-21 20:45:29 -07:00
Alexander Brown
93e7a4f9f9 feat(assets): add ModelInfoPanel for asset browser right panel (#8090)
## Summary

Adds an editable Model Info Panel to show and modify asset details in
the asset browser.

## Changes

- Add `ModelInfoPanel` component with editable display name,
description, model type, base models, and tags
- Add `updateAssetMetadata` action in `assetsStore` with optimistic
cache updates
- Add shadcn-vue `Select` components with design system styling
- Add utility functions in `assetMetadataUtils` for extracting model
metadata
- Convert `BaseModalLayout` right panel state to `defineModel` pattern
- Add slide-in animation and collapse button for right panel
- Add `class` prop to `PropertiesAccordionItem` for custom styling
- Fix keyboard handling: Escape in TagsInput/TextArea doesn't close
parent modal

## Testing

- Unit tests for `ModelInfoPanel` component
- Unit tests for `assetMetadataUtils` functions

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-21 19:43:56 -08:00
Johnpaul Chiwetelu
8261e1d187 feat: add preview version badge for nightly builds (#8222)
## Summary

Adds a "Preview Version" badge to the topbar on nightly builds to help
users identify when they're using an unreleased version and encourage
feedback.

## Changes

- Add `nightly.badge` i18n translations (label and tooltip)
- Create `nightlyBadges.ts` extension with info variant badge
- Load nightly badges extension when `isNightly` is true
- Badge tooltip encourages users to provide feedback via the feedback
button

## Test plan

- [ ] Build and run nightly version, verify "NIGHTLY | Preview Version"
badge appears in topbar
- [ ] Hover over badge, verify tooltip shows: "You are using a nightly
version of ComfyUI. Please use the feedback button to share your
thoughts about these features."
- [ ] Verify stable OSS builds don't show the badge
- [ ] Verify cloud builds don't show the nightly badge

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8222-feat-add-preview-version-badge-for-nightly-builds-2ef6d73d36508102aa0dfc22ef14c9c1)
by [Unito](https://www.unito.io)
2026-01-22 03:32:52 +01:00
Jin Yi
e4d2bc2b59 [feat] Add active jobs display to grid view (#8209)
## Summary
Show active jobs in grid view matching the list view behavior, with
refactored component structure.

## Changes
- **ActiveJobCard**: New component for grid view job display with
progress bar
- **AssetsSidebarGridView**: Extracted grid view logic from
AssetsSidebarTab (matching ListView pattern)
- **Progress styling**: Use `useProgressBarBackground` composable for
consistent progress bar styling
- **Assets header**: Add "Generated/Imported assets" header in grid view
2026-01-22 11:02:28 +09:00
Alexander Brown
482159957e feat: implement progressive pagination for Asset Browser model assets (#8212)
## Summary

Implements progressive pagination for model assets - returns the first
batch immediately while loading remaining batches in the background.

## Changes

### Store (`assetsStore.ts`)
- Adds `ModelPaginationState` tracking (assets Map, offset, hasMore,
loading, error)
- `updateModelsForKey()` returns first batch, then calls
`loadRemainingBatches()` to fetch the rest
- Accessor functions `getAssets(key)`, `isModelLoading(key)` replace
direct Map access

### API (`assetService.ts`)
- Adds `PaginationOptions` interface (`{ limit?, offset? }`)

### Components
- `AssetBrowserModal.vue` uses new accessor API

### Tests
- Updated mocks for new accessor pattern

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8212-feat-implement-progressive-pagination-for-Asset-Browser-model-assets-2ef6d73d36508157af04d1264780997e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-21 17:59:08 -08:00
Alexander Brown
f08b0f44ef fix: Mark comfy-api-plugin as build-time only (#8224)
## Summary

Marks the `comfy-api-plugin` Vite plugin as build-time only by adding
`apply: 'build'`.

This prevents the plugin's transform from running during development
(`vite dev`), improving dev server startup time and avoiding unnecessary
processing when the plugin's output is not needed in development mode.

Also updates `build/tsconfig.json` to use `moduleResolution: "bundler"`
which is the recommended setting for Vite projects.

## Changes

- **build/plugins/comfyAPIPlugin.ts**: Add `apply: 'build'` to restrict
plugin to production builds
- **build/tsconfig.json**: Update `moduleResolution` from `"node"` to
`"bundler"`

## Testing

- `pnpm typecheck` passes
- `pnpm build` produces correct output

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8224-fix-Mark-comfy-api-plugin-as-build-time-only-2ef6d73d36508145a48ae849087fbad7)
by [Unito](https://www.unito.io)
2026-01-21 16:38:18 -08:00
Alexander Brown
f1d1747582 feat: add session download tracking to assetDownloadStore (#8213)
## Summary

Add session download tracking to track which assets were downloaded
during the current session. This enables UI features like:
- Badge count on "Imported" nav showing newly downloaded assets
- Visual indicator on asset cards for recently downloaded items

## Changes

- Add `acknowledged` flag to `AssetDownload` interface
- Add `unacknowledgedDownloads` computed for filtering
- Add `sessionDownloadCount` computed for badge display
- Add `isDownloadedThisSession(identifier)` to check individual assets
- Add `acknowledgeDownload(identifier)` to mark assets as seen

## Testing

- 6 new unit tests covering all session tracking functionality
- Run: `pnpm test:unit -- src/stores/assetDownloadStore.test.ts`

## Related

- Part of Asset Browser improvements (#8090)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8213-feat-add-session-download-tracking-to-assetDownloadStore-2ef6d73d365081538045e8544d26bafa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-21 16:32:30 -08:00
Benjamin Lu
d12c6d7814 fix: sort queue jobs by create time (#8225)
Sort queue overlay ordering by create_time instead of priority so queued
jobs keep their order when completions arrive.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8225-fix-sort-queue-jobs-by-create-time-2ef6d73d3650815a8a81ec9d0b23a4e6)
by [Unito](https://www.unito.io)
2026-01-21 23:13:27 +00:00
Alexander Brown
2db246f494 refactor: restructure BaseModalLayout from flexbox to CSS Grid (#8211)
## Summary

Refactors `BaseModalLayout` from a flexbox-based layout to CSS Grid,
enabling smoother panel transitions and improved layout control.

## Changes

### Layout Restructure

- **Flexbox → CSS Grid**: Replaced nested flexbox with a 3-column CSS
Grid (`nav | main | aside`)
- **Smooth panel transitions**: Panel show/hide now animates via
`grid-template-columns` instead of Vue `<Transition>` with `translateX`
- **Removed transition CSS**: Deleted `.slide-panel-*` and `.fade-*`
transition styles (no longer needed with grid approach)

### Right Panel Improvements

- **Dedicated header**: Added header with close button, right panel
toggle, and customizable title slot (`rightPanelHeaderTitle`,
`rightPanelHeaderActions`)
- **New prop**: Added `rightPanelTitle` prop for simple text title in
right panel header

### UX & Accessibility

- **ESC key handling**: Pressing Escape closes the right panel (if open)
before closing the dialog
- **Accessibility**: Added `aria-label` attributes to all panel toggle
and close buttons
- **i18n**: Added translation keys: `showLeftPanel`, `hideLeftPanel`,
`showRightPanel`, `hideRightPanel`, `closeDialog`

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-21 13:20:39 -08:00
Simula_r
66e6f24980 feat: add workspace session, auth, and store infrastructure (#8194)
## Summary
- Add `teamWorkspaceStore` Pinia store for workspace state management
(workspaces, members, invites, current workspace)
- Add `workspaceApi` client for workspace CRUD, member management, and
invite operations
- Update `useWorkspaceSwitch` composable for workspace switching logic
- Update `useSessionCookie` for workspace-aware sessions
- Update `firebaseAuthStore` for workspace aware auth
- Use `workspaceAuthStore` for workspace auth flow

## Test plan
- [x] 59 unit tests passing (50 store tests + 9 switch tests)
- [x] Typecheck passing
- [x] Lint passing
- [x] Knip passing

Note: This PR depends on the `team_workspaces_enabled` feature flag
being available (already in main).

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8194-feat-add-workspace-session-auth-and-store-infrastructure-2ef6d73d3650814984afe8ee7ba0a209)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:18:47 -07:00
Alexander Brown
fcf2e0e639 feat: add badge support to NavItem component (#8207)
## Summary

Adds optional badge support to the `NavItem` component and `NavItemData`
interface, enabling navigation items in the left sidebar of the Asset
Browser Modal to display counts or status indicators.

## Changes

- **`src/types/navTypes.ts`**: Added optional `badge?: string | number`
property to `NavItemData` interface
- **`src/components/widget/nav/NavItem.vue`**: Added `StatusBadge`
rendering when `badge` prop is provided
- **`src/components/widget/panel/LeftSidePanel.vue`**: Wired `badge`
prop from `NavItemData` to `NavItem` for both grouped and ungrouped
items

## Usage

```ts
const navItem: NavItemData = {
  id: 'assets',
  label: 'Assets',
  icon: 'pi pi-folder',
  badge: 5  // Optional - displays count badge
}
```

## Related

- Builds on #8170 which added queue badge functionality

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8207-feat-add-badge-support-to-NavItem-component-2ef6d73d365081669f86fe2fc618e87f)
by [Unito](https://www.unito.io)
2026-01-21 11:48:22 -08:00
Alexander Brown
7b701ad07b fix: Consistent keydown handling for EditableText and TagsInput escape key (#8204)
## Summary

This PR improves keyboard event handling consistency and fixes an issue
where pressing Escape in nested input components would unintentionally
close parent modals/dialogs.

## Changes

### Keyboard Event Fixes

**TagsInput Escape Key Handling**
- Added `@keydown.escape.stop` handler to `TagsInputInput.vue` to
prevent Escape from bubbling up and closing parent modals
- The handler blurs the input and exits editing mode without propagating
the event

**EditableText keyup → keydown Migration**
- Changed `@keyup.enter` to `@keydown.enter` and `@keyup.escape` to
`@keydown.escape`
- Using `keydown` is more consistent with how other UI frameworks handle
these events and provides more responsive feedback
- Updated corresponding unit tests to use `keydown` triggers

### Why keydown over keyup?

- `keydown` fires immediately when the key is pressed, providing faster
perceived response
- Better consistency with browser/OS conventions for action triggers
- Prevents default behaviors (like form submission) more reliably when
needed
- Aligns with other keyboard handlers in the codebase

## Testing

- Updated `EditableText.test.ts` to use `keydown` events
- Updated `NodeHeader.test.ts` to use `keydown.enter`
- Manual testing: Escape in TagsInput no longer closes parent modal

## Checklist

- [x] Unit tests updated
- [x] Keyboard event handlers consistent
- [x] No breaking changes to component API

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8204-fix-Consistent-keydown-handling-for-EditableText-and-TagsInput-escape-key-2ef6d73d365081f0ac6bed8bcae57657)
by [Unito](https://www.unito.io)
2026-01-21 09:16:13 -08:00
AustinMroz
9a6ead37cb Fix doubled player on VHS LoadAudio in vue (#8206)
In vue mode, the VHS Load Audio (Upload) node had 2 audio previews. This
occurred because the native AudioPreview widget was being applied to any
combo widget with the name `audio`. This native preview does not support
the advanced preview functions VHS provides like seeking to specific
start time, trimming to a target duration, or converting from formats
the browser may not support.

This is fixed through a fairly involved cleanup to instead display the
litegraph AudioUI widget as an AudioPreview widget when in vue mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8206-Fix-doubled-player-on-VHS-LoadAudio-in-vue-2ef6d73d365081ce8907dca2706214a1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-21 00:14:37 -08:00
Alexander Brown
7a1a2c1abb feat(ui): add shadcn-vue Select components (#8205)
## Summary

Adds shadcn-vue Select components built on Reka UI primitives with
design system styling.

## Changes

**New Components** (`src/components/ui/select/`):
- `Select.vue` - Root wrapper using `SelectRoot` from Reka UI
- `SelectTrigger.vue` - Styled trigger with chevron icon
- `SelectContent.vue` - Dropdown content with scroll buttons, z-index
3000 for PrimeVue dialog compatibility
- `SelectItem.vue` - Individual option with check icon
- `SelectGroup.vue`, `SelectLabel.vue`, `SelectSeparator.vue` - Grouping
support
- `SelectScrollUpButton.vue`, `SelectScrollDownButton.vue` - Overflow
navigation
- `SelectValue.vue` - Placeholder/value display

**Styling**:
- Uses design tokens (`bg-secondary-background`,
`text-muted-foreground`, `border-border-default`)
- Iconify icons via `icon-[lucide--*]` classes
- Smooth transitions and focus states

**Documentation**:
- Comprehensive Storybook stories covering all variants
- `AGENTS.md` with component creation guidelines

## Testing

- [x] Storybook stories work correctly
- [x] Components build without errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8205-feat-ui-add-shadcn-vue-Select-components-2ef6d73d365081fd994ddb1123c063e7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-20 23:17:53 -08:00
Alexander Brown
e503482c6f feat: add maxColumns prop to VirtualGrid for responsive column capping (#8210)
## Summary

Add `maxColumns` prop to VirtualGrid for responsive column capping.

## Changes

- **VirtualGrid**: Add `maxColumns` prop to cap grid columns; refactor
inline styles to Tailwind classes; extract spacer height computation to
helper function
- **AssetGrid**: Use `useBreakpoints` to set responsive `maxColumns`
(1-5 based on Tailwind breakpoints)

## Review Focus

- `maxColumns` behavior when `Infinity` vs finite: does
`gridTemplateColumns` override work correctly?
- Tailwind scrollbar classes replacing scoped CSS
2026-01-20 23:17:06 -08:00
Christian Byrne
c7b5f47055 feat(canvas): hide widgets marked advanced unless node.showAdvanced is true (#8147)
Hides widgets marked with `options.advanced = true` on the Vue Node
canvas unless `node.showAdvanced` is true.

## Changes
- Updates `NodeWidgets.vue` template to check `widget.options.advanced`
combined with `nodeData.showAdvanced`
- Updates `gridTemplateRows` computed to exclude hidden advanced widgets
- Adds `showAdvanced` to `VueNodeData` interface in
`useGraphNodeManager.ts`

## Related
- Backend PR that adds `advanced` flag: comfyanonymous/ComfyUI#11939
- Toggle button PR: feat/advanced-widgets-toggle-button

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8147-feat-canvas-hide-widgets-marked-advanced-unless-node-showAdvanced-is-true-2ec6d73d36508179931ce78a6ffd6b0a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-20 23:02:16 -07:00
Jin Yi
d9e5b07c73 [bugfix] Clear queue button now properly removes initializing jobs from UI (#8203)
## Summary

- Fix: Initializing jobs now properly disappear from UI when cancelled
or cleared
- Add `clearInitializationByPromptIds` batch function for optimized Set
operations
- Handle Cloud vs local environment correctly (use `api.deleteItem` for
Cloud, `api.interrupt` for local)

## Problem

When clicking 'Clear queue' button or X button on initializing jobs, the
jobs remained visible in both AssetsSidebarListView and JobQueue
components until page refresh.

## Root Cause

1. `initializingPromptIds` in `executionStore` was not being cleared
when jobs were cancelled/deleted
2. Cloud environment requires `api.deleteItem()` instead of
`api.interrupt()` for cancellation

## Changes

- `src/stores/executionStore.ts`: Export `clearInitializationByPromptId`
and add batch `clearInitializationByPromptIds` function
- `src/composables/queue/useJobMenu.ts`: Add Cloud branch handling and
initialization cleanup
- `src/components/queue/QueueProgressOverlay.vue`: Fix `onCancelItem()`,
`cancelQueuedWorkflows()`, `interruptAll()`
- `src/components/sidebar/tabs/AssetsSidebarTab.vue`: Add initialization
cleanup to `handleClearQueue()`


[screen-capture.webm](https://github.com/user-attachments/assets/0bf911c2-d8f4-427c-96e0-4784e8fe0f08)


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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8203-bugfix-Clear-queue-button-now-properly-removes-initializing-jobs-from-UI-2ef6d73d36508162a55bd84ad39ab49c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:15:58 +09:00
Alexander Brown
73cfbd9833 feat(StatusBadge): add dot mode with CVA variants (#8202)
## Summary

Refactors StatusBadge to use CVA (class-variance-authority) for variant
management and adds a new `dot` mode for label-free status indicators.

## Changes

- **CVA variants** (`statusBadge.variants.ts`): Extracts styling logic
into a CVA config with:
  - `severity`: `default` | `secondary` | `warn` | `danger` | `contrast`
- `variant`: `label` (text badge) | `dot` (small indicator) | `circle`
(numeric badge)
- **StatusBadge.vue**: Simplified component using CVA; auto-selects
`dot` variant when no label provided
- **Storybook stories**: Comprehensive coverage of all severities and
variants

## Breaking Changes

- `label` prop is now optional (was required)
- `label` accepts `string | number` (was `string` only)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8202-feat-StatusBadge-add-dot-mode-with-CVA-variants-2ef6d73d36508120beedd04b2c277227)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-20 20:59:45 -08:00
289 changed files with 16229 additions and 7978 deletions

View File

@@ -300,6 +300,12 @@ When referencing Comfy-Org repos:
Rules for agent-based coding tasks.
### Chrome DevTools MCP
When using `take_snapshot` to inspect dropdowns, listboxes, or other components with dynamic options:
- Use `verbose: true` to see the full accessibility tree including list items
- Non-verbose snapshots often omit nested options in comboboxes/listboxes
### Temporary Files
- Put planning documents under `/temp/plans/`

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

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

View File

@@ -82,9 +82,7 @@ test.describe('Templates', () => {
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.locator(
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
)
.getByRole('button', { name: 'Getting Started' })
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()
@@ -189,9 +187,7 @@ test.describe('Templates', () => {
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
const nav = comfyPage.page
.locator('header')
.filter({ hasText: 'Templates' })
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
@@ -201,7 +197,8 @@ test.describe('Templates', () => {
await comfyPage.page.setViewportSize(mobileSize)
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
// Nav header is clipped by overflow-hidden parent at mobile size
await expect(nav).not.toBeInViewport()
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,21 @@
<meta name="mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<style>
@media (prefers-color-scheme: dark) {
body {
/* Setting it early for background during load */
--bg-color: #202020;
}
}
body {
background-color: var(--bg-color);
background-image: var(--bg-img);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
</style>
<link rel="manifest" href="manifest.json">
</head>

View File

@@ -11,6 +11,6 @@
}
],
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"
"background_color": "#172dd7",
"theme_color": "#f0ff41"
}

View File

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

View File

@@ -584,8 +584,6 @@ body {
height: 100vh;
margin: 0;
overflow: hidden;
background: var(--bg-color) var(--bg-img);
color: var(--fg-color);
min-height: -webkit-fill-available;
max-height: -webkit-fill-available;
min-width: -webkit-fill-available;

View File

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

Before

Width:  |  Height:  |  Size: 693 B

View File

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

Before

Width:  |  Height:  |  Size: 763 B

View File

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

View File

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

12
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -17,6 +17,7 @@
- Clear public interfaces
- Restrict extension access
- Clean up subscriptions
- Only expose state/actions that are used externally; keep internal state private
## General Guidelines

View File

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

View File

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

View File

@@ -1,12 +1,21 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -27,7 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
function createWrapper() {
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -36,7 +45,9 @@ function createWrapper() {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue'
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
}
}
@@ -45,12 +56,17 @@ function createWrapper() {
return mount(TopMenuSection, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
},
directives: {
tooltip: () => {}
@@ -59,6 +75,19 @@ function createWrapper() {
})
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
status,
create_time: 0,
priority: 0
}
}
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
@@ -100,4 +129,104 @@ describe('TopMenuSection', () => {
})
})
})
it('shows the active jobs label with the current count', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
]
await nextTick()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
await nextTick()
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
true
)
expect(
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
).toBe(false)
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
)
})
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
})
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,17 +3,14 @@
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
<h2 class="text-neutral text-base">
{{ $t('sideToolbar.templates', 'Templates') }}
</h2>
</template>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
<template #header-icon>
<i class="icon-[comfy--template]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{
$t('sideToolbar.templates', 'Templates')
}}</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
</template>
<template #header>

View File

@@ -4,9 +4,14 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -36,11 +41,38 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import Dialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
}
</script>
<style>
@@ -55,4 +87,30 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -31,7 +31,12 @@
}}</label>
</div>
<Button variant="secondary" autofocus @click="onCancel">
<Button
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
>
<i class="pi pi-undo" />
{{ $t('g.cancel') }}
</Button>
@@ -73,6 +78,10 @@
<i class="pi pi-eraser" />
{{ $t('desktopMenu.reinstall') }}
</Button>
<!-- Info - just show an OK button -->
<Button v-else-if="type === 'info'" variant="primary" @click="onCancel">
{{ $t('g.ok') }}
</Button>
<!-- Invalid - just show a close button. -->
<Button v-else variant="primary" @click="onCancel">
<i class="pi pi-times" />

View File

@@ -0,0 +1,511 @@
<template>
<div class="grow overflow-auto pt-6">
<div
class="flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke border-inter p-6"
>
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
})
}}
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-64"
/>
</div>
</div>
<!-- Members Content -->
<div class="flex min-h-0 flex-1 flex-col">
<!-- Table Header with Tab Buttons and Column Headers -->
<div
v-if="uiConfig.showMembersList"
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="
activeView === 'pending' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'pending'"
>
{{
$t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
}}
</Button>
</div>
<!-- Date column headers -->
<template v-if="activeView === 'pending'">
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('inviteDate')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</div>
<!-- Members List -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Personal Workspace: show only current user -->
<template v-if="isPersonalWorkspace">
<div
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="userPhotoUrl"
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ userDisplayName }}
<span class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ $t('workspaceSwitcher.roleOwner') }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ userEmail }}
</span>
</div>
</div>
</div>
</template>
<!-- Team Workspace: sorted list (owner first, current user second, then rest) -->
<template v-else>
<div
v-for="(member, index) in filteredMembers"
:key="member.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="
isCurrentUser(member) ? userPhotoUrl : undefined
"
:pt:icon:class="{
'text-xl!': !isCurrentUser(member) || !userPhotoUrl
}"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span
v-if="isCurrentUser(member)"
class="text-muted-foreground"
>
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ getRoleBadgeLabel(member.role) }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
class="flex items-center justify-end"
>
<Button
v-if="!isCurrentUser(member)"
v-tooltip="{
value: $t('g.moreOptions'),
showDelay: 300
}"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="showMemberMenu($event, member)"
>
<i class="pi pi-ellipsis-h" />
</Button>
</div>
</div>
<!-- Member actions menu (shared for all members) -->
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
</template>
</template>
<!-- Pending Invites -->
<template v-else>
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.pendingGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<!-- Invite info -->
<div class="flex items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
</span>
</div>
</div>
<!-- Invite date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<!-- Expiry date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<!-- Actions -->
<div class="flex items-center justify-end gap-2">
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.copyLink'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.copyLink')"
@click="handleCopyInviteLink(invite)"
>
<i class="icon-[lucide--link] size-4" />
</Button>
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.revokeInvite'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="
$t('workspacePanel.members.actions.revokeInvite')
"
@click="handleRevokeInvite(invite)"
>
<i class="icon-[lucide--mail-x] size-4" />
</Button>
</div>
</div>
<div
v-if="filteredPendingInvites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</template>
</div>
</div>
</div>
<!-- Personal Workspace Message -->
<div v-if="isPersonalWorkspace" class="flex items-center">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
</p>
<button
class="underline bg-transparent border-none cursor-pointer"
@click="handleCreateWorkspace"
>
{{ $t('workspacePanel.members.createNewWorkspace') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { d, t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
members,
pendingInvites,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
const sortDirection = ref<'asc' | 'desc'>('desc')
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
const selectedMember = ref<WorkspaceMember | null>(null)
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectedMember.value = member
memberMenu.value?.toggle(event)
}
function isCurrentUser(member: WorkspaceMember): boolean {
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
}
// All members sorted: owners first, current user second, then rest by join date
const filteredMembers = computed(() => {
let result = [...members.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(member) =>
member.name.toLowerCase().includes(query) ||
member.email.toLowerCase().includes(query)
)
}
result.sort((a, b) => {
// Owners always come first
if (a.role === 'owner' && b.role !== 'owner') return -1
if (a.role !== 'owner' && b.role === 'owner') return 1
// Current user comes second (after owner)
const aIsCurrentUser = isCurrentUser(a)
const bIsCurrentUser = isCurrentUser(b)
if (aIsCurrentUser && !bIsCurrentUser) return -1
if (!aIsCurrentUser && bIsCurrentUser) return 1
// Then sort by join date
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function getRoleBadgeLabel(role: 'owner' | 'member'): string {
return role === 'owner'
? t('workspaceSwitcher.roleOwner')
: t('workspaceSwitcher.roleMember')
}
const filteredPendingInvites = computed(() => {
let result = [...pendingInvites.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((invite) =>
invite.email.toLowerCase().includes(query)
)
}
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
result.sort((a, b) => {
const aDate = a[field]
const bDate = b[field]
if (!aDate || !bDate) return 0
const aValue = aDate.getTime()
const bValue = bDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
life: 3000
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.removeMemberDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.removeMemberDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRemove">
{{ $t('workspacePanel.removeMemberDialog.remove') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { memberId } = defineProps<{
memberId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'remove-member' })
}
async function onRemove() {
loading.value = true
try {
await workspaceStore.removeMember(memberId)
toast.add({
severity: 'success',
summary: t('workspacePanel.removeMemberDialog.success'),
life: 2000
})
dialogStore.closeDialog({ key: 'remove-member' })
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.removeMemberDialog.error'),
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.revokeInviteDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.revokeInviteDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRevoke">
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { inviteId } = defineProps<{
inviteId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'revoke-invite' })
}
async function onRevoke() {
loading.value = true
try {
await workspaceStore.revokeInvite(inviteId)
dialogStore.closeDialog({ key: 'revoke-invite' })
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -93,7 +93,7 @@
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { until, useEventListener } from '@vueuse/core'
import {
computed,
nextTick,
@@ -129,7 +129,7 @@ import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { mergeCustomNodesI18n, t } from '@/i18n'
import { t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
@@ -144,12 +144,15 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { UnauthorizedError, api } from '@/scripts/api'
import { UnauthorizedError } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -160,6 +163,9 @@ import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const emit = defineEmits<{
ready: []
@@ -172,11 +178,16 @@ const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const canvasInteractions = useCanvasInteractions()
const bootstrapStore = useBootstrapStore()
const { isI18nReady, i18nError } = storeToRefs(bootstrapStore)
const { isReady: isSettingsReady, error: settingsError } =
storeToRefs(settingStore)
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
@@ -383,50 +394,81 @@ useEventListener(
{ passive: true }
)
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
mergeCustomNodesI18n(i18nData)
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
}
}
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up invite loader during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
async (_newLocale, oldLocale) => {
if (!oldLocale) return
await Promise.all([
until(() => isSettingsReady.value || !!settingsError.value).toBe(true),
until(() => isI18nReady.value || !!i18nError.value).toBe(true)
])
if (settingsError.value || i18nError.value) {
console.warn(
'Somehow the Locale setting was changed while the settings or i18n had a setup error'
)
}
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
}
)
useEventListener(
() => canvasStore.canvas?.canvas,
'litegraph:set-graph',
() => {
workflowStore.updateActiveGraph()
}
)
onMounted(async () => {
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
useVueFeatureFlags()
comfyApp.vueAppReady = true
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
await loadCustomNodesI18n()
try {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
} else {
throw error
return
}
throw settingsError.value
}
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await newUserService().initializeIfNewUser(settingStore)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
newUserService().initializeIfNewUser(settingStore)
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
@@ -459,31 +501,28 @@ onMounted(async () => {
// Load template from URL if present
await workflowPersistence.loadTemplateFromUrlIfPresent()
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// Uses watch because feature flags load asynchronously - flag may be false initially
// then become true once remoteConfig or websocket features are loaded
if (inviteUrlLoader) {
const stopWatching = watch(
() => flags.teamWorkspacesEnabled,
async (enabled) => {
if (enabled) {
stopWatching()
await inviteUrlLoader.loadInviteFromUrl()
}
},
{ immediate: true }
)
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')
const releaseStore = useReleaseStore()
void releaseStore.initialize()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
async () => {
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
},
{ immediate: true }
)
emit('ready')
})

View File

@@ -5,9 +5,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
extensionCommands: { value: new Map() },
loadExtensions: vi.fn(),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(() => []),
invokeExtensionsAsync: vi.fn()
} as Partial<ReturnType<typeof useExtensionService>> as ReturnType<
typeof useExtensionService
>
}
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
@@ -112,12 +129,7 @@ describe('SelectionToolbox', () => {
canvasStore = useCanvasStore()
// Mock the canvas to avoid "getCanvas: canvas is null" errors
canvasStore.canvas = {
setDirty: vi.fn(),
state: {
selectionChanged: false
}
} as any
canvasStore.canvas = createMockCanvas()
vi.resetAllMocks()
})
@@ -184,30 +196,27 @@ describe('SelectionToolbox', () => {
describe('Button Visibility Logic', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
})
it('should show info button only for single selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.info-button').exists()).toBe(false)
})
it('should not show info button when node definition is not found', () => {
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
// mock nodedef and return null
nodeDefMock = null
// remount component
@@ -217,7 +226,7 @@ describe('SelectionToolbox', () => {
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
true
@@ -225,9 +234,9 @@ describe('SelectionToolbox', () => {
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(
@@ -237,15 +246,15 @@ describe('SelectionToolbox', () => {
it('should show frame nodes only for multiple selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
@@ -253,22 +262,22 @@ describe('SelectionToolbox', () => {
it('should show bypass button for appropriate selections', () => {
// Single node selection
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
{ type: 'TestNode1' },
{ type: 'TestNode2' }
] as any
createMockPositionable(),
createMockPositionable()
]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
})
it('should show common buttons for all selections', () => {
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
@@ -286,13 +295,13 @@ describe('SelectionToolbox', () => {
// Single image node
isImageNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [{ type: 'ImageNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
// Single non-image node
isImageNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
@@ -304,13 +313,13 @@ describe('SelectionToolbox', () => {
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [{ type: 'Load3DNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
// Single non-Load3D node
isLoad3dNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
@@ -326,17 +335,17 @@ describe('SelectionToolbox', () => {
// With output node selected
isOutputNodeSpy.mockReturnValue(true)
filterOutputNodesSpy.mockReturnValue([{ type: 'SaveImage' }] as any)
canvasStore.selectedItems = [
{ type: 'SaveImage', constructor: { nodeData: { output_node: true } } }
] as any
filterOutputNodesSpy.mockReturnValue([
{ type: 'SaveImage' }
] as LGraphNode[])
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.execute-button').exists()).toBe(true)
// Without output node selected
isOutputNodeSpy.mockReturnValue(false)
filterOutputNodesSpy.mockReturnValue([])
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.execute-button').exists()).toBe(false)
@@ -352,7 +361,7 @@ describe('SelectionToolbox', () => {
describe('Divider Visibility Logic', () => {
it('should show dividers between button groups when both groups have buttons', () => {
// Setup single node to show info + other buttons
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const dividers = wrapper.findAll('.vertical-divider')
@@ -378,10 +387,13 @@ describe('SelectionToolbox', () => {
['test-command', { id: 'test-command', title: 'Test Command' }]
])
},
invokeExtensions: vi.fn(() => ['test-command'])
} as any)
loadExtensions: vi.fn(),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(() => ['test-command']),
invokeExtensionsAsync: vi.fn()
} as ReturnType<typeof useExtensionService>)
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
@@ -389,12 +401,9 @@ describe('SelectionToolbox', () => {
it('should not render extension commands when none available', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
@@ -404,12 +413,9 @@ describe('SelectionToolbox', () => {
describe('Container Styling', () => {
it('should apply minimap container styles', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -418,12 +424,9 @@ describe('SelectionToolbox', () => {
it('should have correct CSS classes', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -435,12 +438,9 @@ describe('SelectionToolbox', () => {
it('should handle animation class conditionally', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -453,16 +453,18 @@ describe('SelectionToolbox', () => {
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
const forwardEventToCanvasSpy = vi.fn()
mockCanvasInteractions.mockReturnValue({
forwardEventToCanvas: forwardEventToCanvasSpy
} as any)
handleWheel: vi.fn(),
handlePointer: vi.fn(),
forwardEventToCanvas: forwardEventToCanvasSpy,
shouldHandleNodePointerEvents: { value: true } as ReturnType<
typeof useCanvasInteractions
>['shouldHandleNodePointerEvents']
} as ReturnType<typeof useCanvasInteractions>)
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
@@ -475,10 +477,7 @@ describe('SelectionToolbox', () => {
describe('No Selection State', () => {
beforeEach(() => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue({
extensionCommands: { value: new Map() },
invokeExtensions: vi.fn(() => [])
} as any)
mockExtensionService.mockReturnValue(createMockExtensionService())
})
it('should hide most buttons when no items selected', () => {

View File

@@ -6,14 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node',
mode: LGraphEventMode.ALWAYS
function getMockLGraphNode(): LGraphNode {
return createMockLGraphNode({ type: 'TestNode' })
}
vi.mock('@/utils/litegraphUtil', () => ({
@@ -59,21 +59,21 @@ describe('BypassButton', () => {
}
it('should render bypass button', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
it('should have correct test id', () => {
canvasStore.selectedItems = [mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="bypass-button"]')
expect(button.exists()).toBe(true)
})
it('should execute bypass command when clicked', async () => {
canvasStore.selectedItems = [mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode()]
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
@@ -85,8 +85,11 @@ describe('BypassButton', () => {
})
it('should show bypassed styling when node is bypassed', async () => {
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
canvasStore.selectedItems = [bypassedNode] as any
const bypassedNode: Partial<LGraphNode> = {
...getMockLGraphNode(),
mode: LGraphEventMode.BYPASS
}
canvasStore.selectedItems = [bypassedNode as LGraphNode]
vi.spyOn(commandStore, 'execute').mockResolvedValue()
const wrapper = mountComponent()
@@ -100,7 +103,7 @@ describe('BypassButton', () => {
it('should handle multiple selected items', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any
canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()]
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)

View File

@@ -1,3 +1,4 @@
import type { Mock } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
@@ -8,7 +9,20 @@ import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
function createMockWorkflow(
overrides: Partial<LoadedComfyWorkflow> = {}
): LoadedComfyWorkflow {
return {
changeTracker: {
checkState: vi.fn() as Mock
},
...overrides
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
}
// Mock the litegraph module
vi.mock('@/lib/litegraph/src/litegraph', async () => {
@@ -70,11 +84,7 @@ describe('ColorPickerButton', () => {
canvasStore.selectedItems = []
// Mock workflow store
workflowStore.activeWorkflow = {
changeTracker: {
checkState: vi.fn()
}
} as any
workflowStore.activeWorkflow = createMockWorkflow()
})
const createWrapper = () => {
@@ -90,13 +100,13 @@ describe('ColorPickerButton', () => {
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
const button = wrapper.find('button')

View File

@@ -1,23 +1,16 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
// Mock the stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn()
}))
// Mock the utils
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn((node) => !!node?.type)
@@ -37,10 +30,8 @@ vi.mock('@/composables/graph/useSelectionState', () => ({
}))
describe('ExecuteButton', () => {
let mockCanvas: any
let mockCanvasStore: any
let mockCommandStore: any
let mockSelectedNodes: any[]
let mockCanvas: LGraphCanvas
let mockSelectedNodes: LGraphNode[]
const i18n = createI18n({
legacy: false,
@@ -57,27 +48,27 @@ describe('ExecuteButton', () => {
})
beforeEach(async () => {
setActivePinia(createPinia())
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
// Reset mocks
mockCanvas = {
const partialCanvas: Partial<LGraphCanvas> = {
setDirty: vi.fn()
}
mockCanvas = partialCanvas as Partial<LGraphCanvas> as LGraphCanvas
mockSelectedNodes = []
mockCanvasStore = {
getCanvas: vi.fn(() => mockCanvas),
selectedItems: []
}
// Get store instances and mock methods
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
mockCommandStore = {
execute: vi.fn()
}
// Setup store mocks
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
@@ -87,7 +78,7 @@ describe('ExecuteButton', () => {
selectedNodes: {
value: mockSelectedNodes
}
} as any)
} as ReturnType<typeof useSelectionState>)
vi.clearAllMocks()
})
@@ -114,15 +105,16 @@ describe('ExecuteButton', () => {
describe('Click Handler', () => {
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
const commandStore = useCommandStore()
const wrapper = mountComponent()
const button = wrapper.find('button')
await button.trigger('click')
expect(mockCommandStore.execute).toHaveBeenCalledWith(
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'
)
expect(mockCommandStore.execute).toHaveBeenCalledTimes(1)
expect(commandStore.execute).toHaveBeenCalledTimes(1)
})
})
})

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgetsAndNodes } from '../shared'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
@@ -21,21 +21,14 @@ const { t } = useI18n()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
() => nodes
)
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
if (includesAdvanced.value) {
return []
}
return nodes
.map((node) => {
const { widgets = [] } = node

View File

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

View File

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

View File

@@ -38,10 +38,22 @@ describe('searchWidgets', () => {
expect(searchWidgets(widgets, 'width')).toHaveLength(1)
expect(searchWidgets(widgets, 'slider')).toHaveLength(1)
expect(searchWidgets(widgets, 'high')).toHaveLength(1)
expect(searchWidgets(widgets, 'image')).toHaveLength(1)
})
it('should support fuzzy matching (e.g., "high" matches both "height" and value "high")', () => {
const widgets = [
createWidget('width', 'number', '100', 'Size Control'),
createWidget('height', 'slider', '200', 'Image Height'),
createWidget('quality', 'text', 'high', 'Quality')
]
const results = searchWidgets(widgets, 'high')
expect(results).toHaveLength(2)
expect(results.some((r) => r.widget.name === 'height')).toBe(true)
expect(results.some((r) => r.widget.name === 'quality')).toBe(true)
})
it('should handle multiple search words', () => {
const widgets = [
createWidget('width', 'number', '100', 'Image Width'),

View File

@@ -1,11 +1,14 @@
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
export const GetNodeParentGroupKey: InjectionKey<
(node: LGraphNode) => LGraphGroup | null
@@ -17,10 +20,18 @@ export type NodeWidgetsListList = Array<{
widgets: NodeWidgetsList
}>
interface WidgetSearchItem {
index: number
searchableLabel: string
searchableName: string
searchableType: string
searchableValue: string
}
/**
* Searches widgets in a list and returns search results.
* Searches widgets in a list using fuzzy search and returns search results.
* Uses Fuse.js for better matching with typo tolerance and relevance ranking.
* Filters by name, localized label, type, and user-input value.
* Performs basic tokenization of the query string.
*/
export function searchWidgets<T extends { widget: IBaseWidget }[]>(
list: T,
@@ -29,27 +40,48 @@ export function searchWidgets<T extends { widget: IBaseWidget }[]>(
if (query.trim() === '') {
return list
}
const words = query.trim().toLowerCase().split(' ')
return list.filter(({ widget }) => {
const label = widget.label?.toLowerCase()
const name = widget.name.toLowerCase()
const type = widget.type.toLowerCase()
const value = widget.value?.toString().toLowerCase()
return words.every(
(word) =>
name.includes(word) ||
label?.includes(word) ||
type?.includes(word) ||
value?.includes(word)
)
}) as T
const searchableList: WidgetSearchItem[] = list.map((item, index) => {
const searchableItem = {
index,
searchableLabel: item.widget.label?.toLowerCase() || '',
searchableName: item.widget.name.toLowerCase(),
searchableType: item.widget.type.toLowerCase(),
searchableValue: item.widget.value?.toString().toLowerCase() || ''
}
return searchableItem
})
const fuseOptions: IFuseOptions<WidgetSearchItem> = {
keys: [
{ name: 'searchableName', weight: 0.4 },
{ name: 'searchableLabel', weight: 0.3 },
{ name: 'searchableValue', weight: 0.3 },
{ name: 'searchableType', weight: 0.2 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query.trim())
const matchedItems = new Set(
results.map((result) => list[result.item.index]!)
)
return list.filter((item) => matchedItems.has(item)) as T
}
type NodeSearchItem = {
nodeId: NodeId
searchableTitle: string
}
/**
* Searches widgets and nodes in a list and returns search results.
* Searches widgets and nodes in a list using fuzzy search and returns search results.
* Uses Fuse.js for node title matching with typo tolerance and relevance ranking.
* First checks if the node title matches the query (if so, keeps entire node).
* Otherwise, filters widgets using searchWidgets.
* Performs basic tokenization of the query string.
*/
export function searchWidgetsAndNodes(
list: NodeWidgetsListList,
@@ -58,12 +90,26 @@ export function searchWidgetsAndNodes(
if (query.trim() === '') {
return list
}
const words = query.trim().toLowerCase().split(' ')
const searchableList: NodeSearchItem[] = list.map((item) => ({
nodeId: item.node.id,
searchableTitle: (item.node.getTitle() ?? '').toLowerCase()
}))
const fuseOptions: IFuseOptions<NodeSearchItem> = {
keys: [{ name: 'searchableTitle', weight: 1.0 }],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const nodeMatches = fuse.search(query.trim())
const matchedNodeIds = new Set(
nodeMatches.map((result) => result.item.nodeId)
)
return list
.map((item) => {
const { node } = item
const title = node.getTitle().toLowerCase()
if (words.every((word) => title.includes(word))) {
if (matchedNodeIds.has(item.node.id)) {
return { ...item, keep: true }
}
return {
@@ -203,3 +249,33 @@ function repeatItems<T>(items: T[]): T[] {
}
return result
}
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
const settingStore = useSettingStore()
const includesAdvanced = computed(() =>
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return toValue(nodes).map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(
w.options?.canvasOnly ||
w.options?.hidden ||
(w.options?.advanced && !includesAdvanced.value)
)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
return {
widgetsSectionDataList,
includesAdvanced
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,7 +92,7 @@ describe('ResultGallery', () => {
}
},
props: {
allGalleryItems: mockGalleryItems as unknown as ResultItemImpl[],
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
@@ -117,7 +117,10 @@ describe('ResultGallery', () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
const vm: any = wrapper.vm
type GalleryVM = typeof wrapper.vm & {
galleryVisible: boolean
}
const vm = wrapper.vm as GalleryVM
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
@@ -167,7 +170,11 @@ describe('ResultGallery', () => {
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
const pt = galleria.props('pt') as any
interface GalleriaPT {
prevButton?: { style?: string }
nextButton?: { style?: string }
}
const pt = galleria.props('pt') as GalleriaPT
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})

View File

@@ -4,6 +4,10 @@ import { nextTick } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
type ComponentInstance = InstanceType<typeof BaseThumbnail> & {
error: boolean
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
@@ -45,7 +49,7 @@ describe('BaseThumbnail', () => {
it('shows error state when image fails to load', async () => {
const wrapper = mountThumbnail()
const vm = wrapper.vm as any
const vm = wrapper.vm as ComponentInstance
// Manually set error since useEventListener is mocked
vm.error = true

View File

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

View File

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

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