Compare commits

...

36 Commits

Author SHA1 Message Date
Si Feng
c0500f2f4e testenvs 2026-01-23 16:36:28 -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
217 changed files with 12648 additions and 6825 deletions

View File

@@ -539,4 +539,4 @@ For comprehensive troubleshooting and technical support, please refer to our off
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting

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

@@ -189,9 +189,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 +199,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

@@ -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

@@ -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

@@ -9,6 +9,7 @@
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import { useEventListener } from '@vueuse/core'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
@@ -16,10 +17,6 @@ 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'
@@ -27,8 +24,6 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -54,23 +49,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,18 @@
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 { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -36,7 +42,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'
}
}
}
@@ -50,7 +58,12 @@ function createWrapper() {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
},
directives: {
tooltip: () => {}
@@ -59,6 +72,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 +126,39 @@ describe('TopMenuSection', () => {
})
})
})
it('shows the active jobs label with the current count', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
]
await nextTick()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
})

View File

@@ -44,21 +44,21 @@
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="icon"
size="md"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
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">
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -86,6 +86,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,6 +105,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -117,18 +120,27 @@ 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 { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const 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'
)
@@ -138,6 +150,18 @@ const queueHistoryTooltipConfig = computed(() =>
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
@@ -164,6 +188,19 @@ const toggleQueueOverlay = () => {
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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

@@ -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,6 +5,11 @@ 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">
@@ -12,7 +17,7 @@ const canvasStore = useCanvasStore()
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>
@@ -20,7 +25,7 @@ const canvasStore = useCanvasStore()
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>
@@ -213,6 +202,7 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
@@ -220,15 +210,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'
@@ -244,6 +233,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -257,6 +247,8 @@ interface JobOutputItem {
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
@@ -302,9 +294,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(
@@ -405,14 +394,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) => {
@@ -454,14 +443,6 @@ const galleryItems = computed(() => {
})
})
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {
@@ -511,7 +492,13 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
}
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 handleBulkAddToWorkflow = async (assets: AssetItem[]) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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