Compare commits

..

96 Commits

Author SHA1 Message Date
Jin Yi
5d00db2143 test: add missing mocks to useJobMenu.test.ts
- Add isCloud mock for Cloud environment handling
- Add ComfyWorkflow export to workflowStore mock
- Add executionStore mock for clearInitializationByPromptId

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:41:33 +09:00
Jin Yi
6e9c6c6f2e fix: when clear queue button clicked, initialized tasks are not cleared 2026-01-21 13:13:47 +09:00
Comfy Org PR Bot
9669100c14 1.38.8 (#8193)
Patch version increment to 1.38.8

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-20 20:06:56 -08:00
Jin Yi
ac825bdb87 [feat] Show dynamic header based on active assets tab (#8199) 2026-01-21 13:06:44 +09:00
Jin Yi
327e37db04 [bugfix] Use asset_hash for LoadImage node in Cloud mode (#8200)
## Summary
Fix 404 error when adding imported assets to workflow as LoadImage nodes
in Cloud mode.

## Changes
- **What**: Use `asset_hash` (hash-based filename) instead of `name`
(original filename) when creating LoadImage nodes in Cloud mode
- **Files**: `useMediaAssetActions.ts` - modified `addWorkflow` and
`addMultipleToWorkflow` functions
- **Tests**: Added `useMediaAssetActions.test.ts` with Cloud/OSS
filename selection tests

## Review Focus
- Cloud vs OSS branching logic using `isCloud && asset.asset_hash`

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8200-bugfix-Use-asset_hash-for-LoadImage-node-in-Cloud-mode-2ef6d73d365081d785b0d7a94e73c55e)
by [Unito](https://www.unito.io)
2026-01-21 12:13:52 +09:00
AustinMroz
47714c2740 Always wait for next tick before layout init (#7591)
A frequent pattern is to add a node to the graph, and then update the
nodes position afterwards.

Some of these cases (like subgraph unpacking) can set the node position
in advance, but others, (like importA1111) require information on nodes
in order to perform arranging.

Alternatives, like allowing code to either modify `app.configuringGraph`
or otherwise set a temporary state were considered, but create the same
problem of requiring fixes in many places.

As a proposed alternative, when a node is created, an extra tick of
delay is always added before initializing layout.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7591-Always-wait-for-next-tick-before-layout-init-2cc6d73d365081f4ababc38020645670)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-20 18:59:34 -08:00
Jin Yi
f7ac0aa39e [feat] Add queue badge to assets sidebar tab (#8170) 2026-01-21 10:49:36 +09:00
Alexander Brown
e83f33aeb2 feat: When a list of strings is received, show all of them. (#8195)
## Summary

Show all of the received strings, double newline separated.
2026-01-20 17:37:12 -08:00
Alexander Brown
b1dfbfaa09 chore: Replace prettier with oxfmt (#8177)
Configure oxfmt ignorePatterns to exclude non-JS/TS files (md, json,
css, yaml, etc.) to match previous Prettier behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8177-chore-configure-oxfmt-to-format-only-JS-TS-Vue-files-2ee6d73d3650815080f3cc8a4a932109)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-20 16:44:08 -08:00
Simula_r
e6ef99e92c feat: add isCloud guard to team workspaces feature flag (#8192)
Ensures the team_workspaces_enabled feature flag only returns true when
running in cloud environment, preventing the feature from activating in
local/desktop installations.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8192-feat-add-isCloud-guard-to-team-workspaces-feature-flag-2ee6d73d3650810bb1d7c1721ebcdd44)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-20 16:13:54 -08:00
Christian Byrne
f5a784e561 fix: add plurilization to node pack count in custom node manager dialog (#8191) 2026-01-21 08:52:40 +09:00
Christian Byrne
e8b45204f2 feat(panel): add collapsible Advanced Inputs section for widgets marked advanced (#8146)
Adds a collapsible 'Advanced Inputs' section to the right-side panel
that displays widgets marked with `options.advanced = true`.

<img width="1903" height="875" alt="image"
src="https://github.com/user-attachments/assets/5f76e680-7904-4c43-b42b-1b98f8f78458"
/>


## Changes
- Filters normal widgets to exclude advanced ones
- Adds new `advancedWidgetsSectionDataList` computed for advanced
widgets
- Renders a collapsible section (collapsed by default) for advanced
widgets

## Related
- Backend PR that adds `advanced` flag: comfyanonymous/ComfyUI#11939

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8146-feat-panel-add-collapsible-Advanced-Inputs-section-for-widgets-marked-advanced-2ec6d73d36508120af1af27110a6fb96)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
2026-01-20 15:22:25 -07:00
Christian Byrne
5df793b721 feat: add feature usage tracker for nightly surveys (#8175)
Introduces `useFeatureUsageTracker` composable that tracks how many
times a user has used a specific feature, along with first and last
usage timestamps. Data persists to localStorage using `@vueuse/core`'s
`useStorage`. This composable provides the foundation for triggering
surveys after a configurable number of feature uses. Includes
comprehensive unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8175-feat-add-feature-usage-tracker-for-nightly-surveys-2ee6d73d36508118859ece6fcf17561d)
by [Unito](https://www.unito.io)
2026-01-20 14:35:54 -07:00
AustinMroz
79d3b2c291 Fix properties context menu (#8188)
A tiny fix for a regression introduced in #7817 that prevented changing
a node's properties through the litegraph context menu.
<img width="838" height="568" alt="image"
src="https://github.com/user-attachments/assets/a73e8da4-f5ff-4e65-8003-55883f8d08be"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8188-Fix-properties-context-menu-2ee6d73d365081ba8844dd3c8d74432d)
by [Unito](https://www.unito.io)
2026-01-20 13:31:56 -08:00
Jin Yi
916c1248e3 [bugfix] Fix search bar height alignment in MediaAssetFilterBar (#8171) 2026-01-21 06:10:31 +09:00
Jin Yi
b5f91977c8 [bugfix] Add spacing between action buttons in node library sidebar (#8172) 2026-01-20 14:48:44 +09:00
Christian Byrne
a8b4928acc feat(canvas): show 'Show Advanced' button on nodes with advanced widgets (#8148)
Extends the existing 'Show Advanced' button (previously subgraph-only)
to also appear on regular nodes that have widgets marked with
`options.advanced = true`.

## Changes
- Updates `showAdvancedInputsButton` computed to check for advanced
widgets on regular nodes
- Updates `handleShowAdvancedInputs` to set `node.showAdvanced = true`
and trigger canvas redraw for regular nodes

## Related
- Backend PR that adds `advanced` flag: comfyanonymous/ComfyUI#11939
- Canvas hide PR: feat/advanced-widgets-canvas-hide (this PR provides
the toggle for that)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8148-feat-canvas-show-Show-Advanced-button-on-nodes-with-advanced-widgets-2ec6d73d36508155a8adfa0a8ec84d46)
by [Unito](https://www.unito.io)
2026-01-19 21:57:08 -07:00
AustinMroz
7f25280da4 Fix padding, color, and move to reka-ui popover (#8164)
- Fixes some options, like decrement, being off center
- Fixes button being very hard to see on light themes
- Moves the popover to use our fancy new reka-ui Popover component
instead of primvue
- Since the display control is no longer in the ValueControlPopover,
loading is now actually async
 
Most changed lines in `ValueControlPopover` are just indentation.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5867d70c-a606-4092-a5f8-dd18ecda5b6f"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/7bbaf036-77da-4c98-acb0-4b142e4a4761"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8164-Fix-padding-color-and-move-to-reka-ui-popover-2ed6d73d3650817ea314f04699f1387f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-19 19:32:40 -08:00
Christian Byrne
4bf9b94cd4 feat: add isNightly build flag for nightly-only features (#8149)
## Summary

Adds a compile-time `__IS_NIGHTLY__` constant that detects whether the
build is from the main branch (nightly) or a core/* branch (RC/stable).
The detection logic in vite.config.mts auto-detects based on
`GITHUB_REF_NAME === 'main'` in CI, with explicit override support via
`IS_NIGHTLY` environment variable. Exports `isNightly` from
`src/platform/distribution/types.ts` for use throughout the codebase.
Includes unit tests for the detection logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8149-feat-add-isNightly-build-flag-for-nightly-only-features-2ec6d73d365081c09930edec1c6644f5)
by [Unito](https://www.unito.io)
2026-01-19 20:22:46 -07:00
Terry Jia
a2246cce7a remove mouse event (#8162)
## Summary

replace https://github.com/Comfy-Org/ComfyUI_frontend/pull/7963, fix on
kjnodes instead https://github.com/kijai/ComfyUI-KJNodes/pull/514

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8162-remove-mouse-event-2ed6d73d365081999a5df76eabdfb89f)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-19 20:11:56 -07:00
Comfy Org PR Bot
d73e8e4aa3 1.38.7 (#8167)
Patch version increment to 1.38.7

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-19 17:26:03 -08:00
Johnpaul Chiwetelu
7ef4ea6f25 Road to No Explicit Any Part 7: Scripts and Dialog Cleanup (#8092)
## Summary

Continues the TypeScript strict typing improvements by removing `any`
types from core scripts and dialog components.

### Changes

**api.ts (6 instances)**
- Define `V1RawPrompt` and `CloudRawPrompt` tuple types for queue prompt
formats
- Export `QueueIndex`, `PromptInputs`, `ExtraData`, `OutputsToExecute`
from apiSchema
- Type `#postItem` body, `storeUserData` data, and `getCustomNodesI18n`
return

**groupNodeManage.ts (all @ts-expect-error removed)**
- Add `GroupNodeConfigEntry` interface to LGraph.ts
- Extend `GroupNodeWorkflowData` with `title`, `widgets_values`, and
typed `config`
- Type all class properties with definite assignment assertions
- Type all method parameters and event handlers
- Fix save button callback with proper generic types for node ordering

**changeTracker.ts (4 instances)**
- Type `nodeOutputs` as `Record<string, ExecutedWsMessage['output']>`
- Type prompt callback with `CanvasPointerEvent` and proper value types

**asyncDialog.ts and dialog.ts**
- Make `ComfyAsyncDialog` generic with `DialogAction<T>` type
- Type `ComfyDialog` constructor and show method parameters
- Update `ManageGroupDialog.show` signature to match base class

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Sourcegraph checks for external usage

---
Related: Continues from #8083

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8092-Road-to-No-Explicit-Any-Part-7-Scripts-and-Dialog-Cleanup-2ea6d73d365081fbb890e73646a6ad16)
by [Unito](https://www.unito.io)
2026-01-20 00:41:40 +00:00
Alexander Brown
d5f17f7d9f chore: migrate Vite config to Vite 8 beta (Rolldown) (#8152)
Prepares Vite config for Vite 8 by migrating from esbuild/Rollup to
Rolldown/Oxc.

## Changes
- Migrate `build.rollupOptions` → `build.rolldownOptions`
- Replace `manualChunks` with `codeSplitting.groups`
- Update Storybook config with `strictExecutionOrder` for module loading
compatibility

## Testing
- [x] `pnpm typecheck` passes
- [x] `pnpm build` succeeds
- [x] `pnpm test:unit` passes

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-19 16:36:16 -08:00
Johnpaul Chiwetelu
0d0576faab feat: optimize empty search to use cached /nodes endpoint (#8159)
## Summary

Optimizes the Manager dialog to use the cached `GET /nodes` endpoint
instead of `GET /nodes/search` for empty search queries (when the dialog
first opens). This significantly reduces Algolia usage since empty
searches account for the majority of search requests.

## Changes

- **registrySearchProvider.ts**: Modified `searchPacks()` to detect
empty queries and route them to `listAllPacks()` instead of `search()`
- **registrySearchProvider.test.ts**: Added 5 new test cases covering
empty query behavior
- Cache clearing now clears both `search` and `listAllPacks` caches

## Technical Details

**Empty Query Flow (NEW):**
- Query: `""` or whitespace
- Endpoint: `GET /nodes?limit=X&page=Y`
- Cache: Server-side cached (via omitting `latest` parameter)
- Result: Fast, cached node pack list

**Non-Empty Query Flow (UNCHANGED):**
- Query: Any non-empty string
- Endpoint: `GET /nodes/search?search=X` or `comfy_node_search=X`
- Result: Search results as before

## Testing

```bash
pnpm test:unit -- src/services/providers/registrySearchProvider.test.ts
pnpm typecheck
```
2026-01-20 00:03:17 +01:00
AustinMroz
b0d7a7f0f4 Control widget fixes (#8160)
#8112 updated control widgets to be disabled when the controlled widget
is disabled. However, some workflows already exist that contain a
promoted control widget which does not function. This widget wouldn't be
marked as disabled (and thus, demoted) until the interior subgraph was
entered as updating `computedDisabled` is tacked to node draw. This is
fixed by having subgraphs eagerly update the `computedDisabled` state on
each node when configured.

Additionally, when `createCopyForNode` was used, linkedWidget retained
pointers to widgets which no longer have relation to the newly cloned
widget. This is resolved by instead not copying linkedWidgets.
Functionally, linkedWidgets is only used for control widgets and not
copying has the effect of ensuring that seed widgets linked to a
subgraph input will not display a control popover button in vue mode
which does nothing.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8160-Control-widget-fixes-2ed6d73d3650816cb397f83f558471b3)
by [Unito](https://www.unito.io)
2026-01-19 12:59:01 -08:00
Comfy Org PR Bot
12ee5de73b 1.38.6 (#8154)
Patch version increment to 1.38.6

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-01-18 19:15:37 -08:00
AustinMroz
6db4750d96 Fix crosshair cursor in vue mode (#8120)
When the mouse cursor is at the very edge of a a node in vue mode, a
crosshair cursor will sometimes display. This happens because the mouse
is over the canvas, and `LGraphCanvas.processMouseMove` determines the
cursor is still above the node.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8120-Fix-crosshair-cursor-in-vue-mode-2eb6d73d36508116a3cfdd407c5e1e9c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-18 11:00:00 -08:00
Rizumu Ayaka
30907f99f1 chore: move renameWidget function to widgetUtil.ts (#8042)
related:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7812#discussion_r2685121387

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8042-chore-move-renameWidget-function-to-widgetUtil-ts-2e86d73d3650813fa502d38b1ca53ab0)
by [Unito](https://www.unito.io)
2026-01-17 21:30:00 -07:00
AustinMroz
284bdce61b Add a slider indicator for number widgets in vue mode. (#8122)
Sometimes it's difficult to gauge the valid range of values for a
widget. Litegraph includes a "slider" widget which displays the distance
from the min and max values as a colored bar. However, this
implementation is rather strongly disliked because it prevents entering
an exact number. Vue mode makes it simple to add just the indicator onto
our existing widget.

In addition to requiring both min and max be set, not every widget would
want this functionality. It's not useful information for seed, but also
has potential to cause confusion on widgets like CFG, that allow
inputting numbers up to 100 even though values beyond ~15 are rarely
desirable.

As a proposed heuristic, the ratio of "step" to distance between min and
max is currently used, but this could fairly easily be changed to an
opt-in only system.

<img width="617" height="487" alt="image"
src="https://github.com/user-attachments/assets/9c5f2119-0a03-4b56-bcf5-e4a0d0250784"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8122-Add-a-slider-indicator-for-number-widgets-in-vue-mode-2eb6d73d365081218fc8e86f37001958)
by [Unito](https://www.unito.io)
2026-01-17 21:28:48 -07:00
Comfy Org PR Bot
7fcef2ba89 1.38.5 (#8138)
Patch version increment to 1.38.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8138-1-38-5-2ec6d73d365081b6bf57fd29cb56998c)
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-18 03:55:35 +00:00
Christian Byrne
54db655a23 feat: make subgraphs blueprints appear higher in node library sidebar (#8140)
## Summary

Changes insertion order so subgraph blueprints are inserted first and
therefore appear highest in node library sidebar (when using default
'original' ordering).

<img width="1003" height="725" alt="image"
src="https://github.com/user-attachments/assets/3f1ea61c-4191-4dd5-8c10-17cd91b6a732"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8140-feat-make-subgraphs-blueprints-appear-higher-in-node-library-sidebar-2ec6d73d3650816f8164f0991b81c116)
by [Unito](https://www.unito.io)
2026-01-17 20:43:24 -07:00
Terry Jia
82c3cd3cd2 add thumbnail for 3d generation (#8129)
## Summary

add thrumbnail for 3d genations, feature requested by @PabloWiedemann 

## Screenshots


https://github.com/user-attachments/assets/4fb9b88b-dd7b-4a69-a70c-e850472d3498

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8129-add-thumbnail-for-3d-generation-2eb6d73d365081f2a30bc698a4fde6e0)
by [Unito](https://www.unito.io)
2026-01-17 20:32:32 -07:00
AustinMroz
c9d74777ba Migrate parentIds when converting to subgraph (#5708)
The parentId property on links and reroutes was not handled at all in
the "Convert to Subgraph" code.
This needs to be addressed in 4 cases
- A new external input link must have parentId set to the first
non-migrated reroute
- A new external output link must have the parentId of it's eldest
remaining child set to undefined
- A new internal input link must have the parentId of it's eldest
remaining child set to undefined
- A new internal output link must have the parentId set to the first
migrated reroute

This is handled in two parts by adding logic where the boundry links is
created
- The change involves mutation of inputs (which isn't great) but the
function here was already mutating inputs into an invalid state
  - @DrJKL Do you see a quick way to better fix both these cases?

Looks like litegraph tests aren't enabled and cursory glance shows
multiple need to be updated to reflect recent changes. I'll still try to
add some tests anyways.
EDIT: Tests are non functional. Seems the subgraph conversion call
requires the rest of the frontend is running and has event listeners to
register the subgraph node def. More work than anticipated, best
revisited later

Resolves #5669

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5708-Migrate-parentIds-when-converting-to-subgraph-2746d73d365081f78acff4454092c74a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-17 19:13:05 -08:00
Terry Jia
be8916b4ce feat: Add visual crop preview widget for ImageCrop node - widget ImageCrop (#7825)
## Summary

Another implementation for image crop node, alternative for
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7014
As discussed with @christian-byrne and @DrJKL we could have single
widget - IMAGECROP with 4 ints and UI preview.

However, this solution requires changing the definition of image crop
node in BE (sent
[here](https://github.com/comfyanonymous/ComfyUI/pull/11594)), which
will break the exsiting workflow, also it would not allow connect
separate int node as input, I am not sure it is a good idea.

So I keep two PRs openned for references

## Screenshots


https://github.com/user-attachments/assets/fde6938c-4395-48f6-ac05-6282c5eb8157

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7825-feat-Add-visual-crop-preview-widget-for-ImageCrop-node-widget-ImageCrop-2dc6d73d3650812bb8a2cdff4615032b)
by [Unito](https://www.unito.io)
2026-01-17 17:09:16 -05:00
Alexander Brown
de2e37ec8e chore: merge vitest config into vite.config.mts (#8132)
Moves vitest configuration from `vitest.config.ts` into the `test`
section of `vite.config.mts` and deletes the separate vitest config
file.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8132-chore-merge-vitest-config-into-vite-config-mts-2eb6d73d365081ab81b5dca11fadf13a)
by [Unito](https://www.unito.io)
2026-01-17 13:02:55 -08:00
Alexander Brown
e5ff329008 feat: upgrade vite to v8.0.0-beta.8 (Rolldown-powered) (#8127)
## Summary

Upgrades Vite from v7.3.0 to v8.0.0-beta.8, which uses Rolldown
(Rust-based bundler) instead of Rollup.

## Changes

- Updated `vite` to `^8.0.0-beta.8` in pnpm-workspace.yaml catalog
- Added pnpm overrides to ensure all dependencies (including vitest) use
Vite 8

## Notes

- Vite 8 is still in **beta** - no stable release yet
- Uses [Rolldown](https://rolldown.rs/) instead of Rollup for production
builds
- Build, typecheck, and lint all pass
- Per the [Vite 8 migration
guide](https://vite.dev/blog/announcing-vite8-beta), pnpm overrides are
required for tools like Vitest that bundle their own Vite types

## Testing

- [x] `pnpm typecheck` passes
- [x] `pnpm build` succeeds (~13s build time)
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8127-feat-upgrade-vite-to-v8-0-0-beta-8-Rolldown-powered-2eb6d73d365081e3bdb6f500e140eb88)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-17 12:17:16 -08:00
AustinMroz
d3bd85db7f Fix roundness of slot error indicator in vue (#8123)
When a node has a missing input connection, the slot is highlighted with
a red outline. This PR makes the error indicator round like the slot
instead of an oval.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/2d5ebc93-fa74-492d-92a7-3a1b3af4754f"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/57c15503-d94a-48d7-8c35-7760f1b860e6"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8123-Fix-roundness-of-slot-error-indicator-in-vue-2eb6d73d3650810383a2f5532117c29f)
by [Unito](https://www.unito.io)
2026-01-16 22:07:54 -08:00
Brian Jemilo II
94706b5b04 Check for empty object (#8075)
## Summary

<!-- One sentence describing what changed and why. -->

## Changes
- **What**: <!-- Core functionality added/modified -->
PNG images causes getWorkflowDataFromFile() to return an empty object,
added a check to handle it.

## Review Focus
<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->
I don't think it exists yet? From Slack Conversation. Just make sure to
use a PNG image and not a JPEG disguised as a PNG.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8075-Check-for-empty-object-2e96d73d3650816b8812eb7244b48f1a)
by [Unito](https://www.unito.io)
2026-01-16 22:03:54 -08:00
pythongosssss
851e8beb29 Fix dragging Vue nodes into canvas from library (#8118)
## Summary

Updates the node preview rendering to use the same app context as the
main app so it can access the same plugins

## Changes

Assigns manually created vnode app context to the current instances
context

## Review Focus

This is using somewhat advanced/almost-internal Vue functionality,
however I couldn't come up with a better alternative that didn't require
recreating an entirely new app and re-registering all dependencies or
redoing how draggable node previews are done.
The draggable image needs to be rendered synchronously, so rendering a
node in the active app and capturing that isn't possible to guarantee to
be done synchronously (afaik - suggestions welcome)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8118-Fix-dragging-Vue-nodes-into-canvas-from-library-2eb6d73d365081a0a956d8280e009592)
by [Unito](https://www.unito.io)
2026-01-16 22:50:54 -07:00
Comfy Org PR Bot
7b3a9b40a5 1.38.4 (#8116)
Patch version increment to 1.38.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8116-1-38-4-2eb6d73d36508140a132e7d14241e851)
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-16 21:27:37 -08:00
pythongosssss
3bbae61763 Decouple node help between sidebar and right panel (#8110)
## Summary

When the node library is open and you click on the node toolbar info
button, this causes the node library info panel & right panel node info
to show the same details.

## Changes

- Extract useNodeHelpContent composable so NodeHelpContent fetches its
own content, allowing multiple panels to show help independently
- Remove sync behavior from NodeHelpPage that caused left sidebar to
change when selecting different graph nodes since we want to prioritise
right panel for this behavior
- Add telemetry tracking for node library help button to identify how
frequently this is used

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8110-Decouple-node-help-between-sidebar-and-right-panel-2ea6d73d365081a9b3afd25aa51b34bd)
by [Unito](https://www.unito.io)
2026-01-16 22:13:23 -07:00
AustinMroz
3fcebe758b Disable control widgets on link to parent (#8112)
When a link is made to a widget with control (like seed) , the control
widget can no longer be used to update it's state. To better communicate
this, the control widgets are now given the disabled property when their
parent widget is linked.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/9b6c6c02-2481-486a-bb07-c19d00abe36d"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/837000ac-8a12-4d51-879b-a58e0577ff10"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8112-Disable-control-widgets-on-link-to-parent-2ea6d73d365081afad77db6c5f56e085)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-16 22:11:00 -07:00
Christian Byrne
0439f744b9 feat: enable new queue progress by default (#8121)
## Summary

Enable the new queue progress UI/UX by default in order to test it and
be able to ask community members what they think (they can easily test
with `--front-end-version Comfy-Org/ComfyUI_frontend@latest`).

More context:
[thread](https://comfy-organization.slack.com/archives/C0A80028GTA/p1768534815533549)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8121-feat-enable-new-queue-progress-by-default-2eb6d73d365081a2ae23d71243369984)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-16 21:49:14 -07:00
AustinMroz
9d8e54a51c Fix dragndrop subgraph widgets sticking to cursor (#8114)
Under some circumstances, subgraph widgets in the properties panel would
have incorrect drag state
- Debouncing list state would cause a race condition where a double
click would initiate a drag on an element that no longer exists
  - This is solved by removing the debounce.
- Right clicking on widgets would initiate a drag
  - Dragging now explicity requires a primary button click.
Additionally, drag is now ended on `pointercancel`. This is purely for
safety and may not actually be required.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8114-Fix-dragndrop-subgraph-widgets-sticking-to-cursor-2ea6d73d365081f08515e8231bd87bdc)
by [Unito](https://www.unito.io)
2026-01-16 20:36:36 -08:00
AustinMroz
69512b9b28 Fix asset selection in litegraph (#8117)
#8074 included some refactoring to the asset dialogue to ensure that it
wouldn't pop up multiple times in vue mode

But moving the openModal function to be contained in options means that
`this` is no longer the widget, but instead the options object. This is
fixed by requiring that widget be explicitly passed as a parameter.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8117-Fix-asset-selection-in-litegraph-2eb6d73d36508176b5a3f6d21964be39)
by [Unito](https://www.unito.io)
2026-01-16 19:17:10 -07:00
Johnpaul Chiwetelu
afe8ce720e fix: adjust toolbox sizing to match Figma design (#8115)
## Summary
- Adjusts selection toolbox to match Figma design specifications
- Changes padding from `p-2` to `p-1` (4px)
- Changes height from `h-12` to `h-10` (40px)

## Test plan
- [ ] Verify toolbox appears with correct dimensions when selecting
nodes
- [ ] Check visual alignment matches Figma design

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8115-fix-adjust-toolbox-sizing-to-match-Figma-design-2ea6d73d365081c786e9d44388dba092)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-17 00:38:58 +01:00
Alexander Brown
e4308a7258 refactor: rename CLAUDE.md to AGENTS.md (#8052)
## Summary

Pure rename of CLAUDE.md files to AGENTS.md (no content changes).

## Changes

| Old Path | New Path |
|----------|----------|
| `.github/CLAUDE.md` | `.github/AGENTS.md` |
| `.storybook/CLAUDE.md` | `.storybook/AGENTS.md` |
| `browser_tests/CLAUDE.md` | `browser_tests/AGENTS.md` |
| `src/CLAUDE.md` | `src/AGENTS.md` |
| `src/components/CLAUDE.md` | `src/components/AGENTS.md` |
| `src/lib/litegraph/CLAUDE.md` | `src/lib/litegraph/AGENTS.md` |

Root `CLAUDE.md` deleted (content will be merged into `AGENTS.md` in
follow-up PR).

## Follow-up

A second PR will add glob-based guidance files and consolidate
redundancies.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-16 13:32:18 -08:00
AustinMroz
cd4999209b Improve linear compatibility with Safari, run button metadata (#8107)
I downloaded the oldest webkit based browser I could find after reports
of display issues. The changes here cause some minor styling tweaks, but
should be more compatible overall. A quick list of fixed issues
- Center panel placeholder had incorrect aspect ratio
<img height="500" alt="image"
src="https://github.com/user-attachments/assets/b75e3c49-20d9-4d6e-bca4-95cc8a73f821"
/>
- Image previews had incorrect aspect ratio (But the other way around)
<img height="500" alt="image"
src="https://github.com/user-attachments/assets/b8ebc58e-2655-41f8-a3b4-70ba65940612"
/>
- On mobile, output groups would flex incorrectly, resulting in a large
gap between them
<img height="500" alt="image"
src="https://github.com/user-attachments/assets/ed7e8e43-c34e-4ffc-a3ee-126b1a2ef4e0"
/>



Also moves the run button trigger source to a new 'linear' type


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8107-Improve-linear-compatibility-with-Safari-run-button-metadata-2ea6d73d3650814e89cbc190b6ca8f87)
by [Unito](https://www.unito.io)
2026-01-16 11:36:52 -08:00
Yoland Yan
7556e3ef39 Update beta message in linear mode (#8106)
This pull request makes a minor update to the user interface text for
Simple Mode. The "beta" label is now more descriptive, clarifying that
Simple Mode is in beta and inviting feedback.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8106-Update-beta-message-in-linear-mode-2ea6d73d3650812193a7cd8f625ea682)
by [Unito](https://www.unito.io)
2026-01-16 11:25:26 -08:00
Alexander Brown
d93c02c437 Revert "fix: run E2E tests after i18n completes on release PRs" (#8105)
Reverts Comfy-Org/ComfyUI_frontend#8091

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8105-Revert-fix-run-E2E-tests-after-i18n-completes-on-release-PRs-2ea6d73d365081f2bd26cdc932acf7fb)
by [Unito](https://www.unito.io)
2026-01-16 11:24:50 -08:00
Johnpaul Chiwetelu
b979ba8992 fix: run E2E tests after i18n completes on release PRs (#8091)
## Summary
- Fixes issue where locale commits would cancel in-progress E2E tests on
release PRs
- E2E tests now run **once** after i18n workflow completes for
version-bump PRs

## Changes
1. **Modified `ci-tests-e2e.yaml`**:
   - Added `workflow_call` trigger with `ref` and `pr_number` inputs
   - Added skip condition for version-bump PRs on `pull_request` trigger
- Updated checkout steps to use `inputs.ref` when called via
`workflow_call`

2. **Created `ci-tests-e2e-release.yaml`**:
   - Triggers on `workflow_run` completion of `i18n: Update Core`
   - Only runs for version-bump PRs from main repo
- Calls original E2E workflow via `workflow_call` (no job duplication)

## How it works
**Regular PRs:** `CI: Tests E2E` runs normally via `pull_request`
trigger

**Version-bump PRs:**
1. `CI: Tests E2E` skips (setup job condition fails)
2. `i18n: Update Core` runs and commits locale updates
3. `CI: Tests E2E (Release PRs)` triggers after i18n completes
4. Calls `CI: Tests E2E` via `workflow_call`
5. E2E tests run to completion without cancellation

## Test plan
- [x] Tested workflow_call chain on fork
- [x] Verify version-bump PR skips regular E2E workflow
- [x] Verify E2E runs after i18n completes on next release

Fixes the issue identified during v1.38.2 release where locale commits
caused E2E tests to restart multiple times.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8091-fix-run-E2E-tests-after-i18n-completes-on-release-PRs-2ea6d73d36508151a315ed1f415afcc6)
by [Unito](https://www.unito.io)
2026-01-16 17:40:27 +01:00
Jin Yi
0288b02113 [refactor] Manager dialog simplification (#8041)
## Summary
Simplifies the Manager dialog by consolidating components and using
BaseModalLayout with v-model support for right panel state.

## Changes
- **Consolidation**: Merged ManagerDialogContent, ManagerHeader,
ManagerNavSidebar, RegistrySearchBar, and SearchFilterDropdown into
single ManagerDialog component
- **Right panel**: Added v-model:rightPanelOpen to BaseModalLayout for
external panel state control; clicking a node card now auto-opens the
info panel
- **Cleanup**: Removed unused useResponsiveCollapse composable, TabItem
and SearchOption types
- **UI tweaks**: Moved action buttons (Install All/Update All) from
header-right-area to contentFilter area


[manager-capture.webm](https://github.com/user-attachments/assets/2dd6092a-965d-4885-8ba6-6a2cc51f024a)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8041-refactor-Manager-dialog-simplification-2e86d73d3650815ba699e49a2748b682)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-15 23:32:02 -08:00
pythongosssss
ddac3dca1d Allow users to drag actionbar over tabs (#8068)
## Summary

A common bit of feedback is that users want to be able to drag the
actionbar into the very top of their window. Currently the actionbar is
clamped to not allow it to overlap the tabs, this update removes that
restriction & fixes padding for the top menu section when the UI
elements are hidden within it adding additional gaps.

## Changes

- Remove clamping on actionbar position
- Fix padding on top menu section

## Screenshots (if applicable)

Before:
<img width="192" height="119" alt="image"
src="https://github.com/user-attachments/assets/c35c89ed-ec30-45ff-8ebd-8ad68dbd4212"
/>

After:
<img width="134" height="120" alt="image"
src="https://github.com/user-attachments/assets/adc32c43-e3ab-4c7b-a8ff-360fd39d6bf1"
/>

Actionbar over tabs:
<img width="465" height="173" alt="image"
src="https://github.com/user-attachments/assets/d1502911-1e15-4082-ba2e-b8906e329b70"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8068-Allow-users-to-drag-actionbar-over-tabs-2e96d73d365081708491f3c54968df3a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-16 00:00:09 -07:00
Christian Byrne
6714b958c7 chore: remove dead browser test fixture after Jobs API migration (#8099)
## Summary

Removes  which is dead code left over from the Jobs API migration.

## Details

This fixture file:
- References legacy and types that were removed in the Jobs API
migration
- Is not referenced anywhere in the codebase
- Cannot be used since the types it imports no longer exist

## Related PRs

Follow-up cleanup to the Jobs API migration:
- #7169 - Add Jobs API infrastructure (PR 1 of 3)
- #7170 - Migrate to Jobs API (PR 2 of 3)  
- #7650 - Encapsulate error extraction in TaskItemImpl getters

## Testing

-  Typecheck passes
-  No references to this file in the codebase
-  File imports types that no longer exist (cannot be used)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8099-chore-remove-dead-browser-test-fixture-after-Jobs-API-migration-2ea6d73d36508172be89c9c5a74b33ee)
by [Unito](https://www.unito.io)
2026-01-15 23:12:37 -07:00
AustinMroz
a6b6857e37 Fix copypasted primitives inside subgraphs (#8094)
Copying a subgraph which contains primitive nodes would cause the
primitives to fail to initialize. This was caused because they did not
have their `onGraphConfigured` and `onAfterGraphConfigured` callbacks
applied.

There's already a copy of `forEachNode` in
`@/utils/graphTraversalUtil.ts`, but the method is small and I want to
avoid litegraph referencing outside code.

See also #6606, where a similar fix was needed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8094-Fix-copypasted-primitives-inside-subgraphs-2ea6d73d365081f189f1ea4c9248f5ed)
by [Unito](https://www.unito.io)
2026-01-15 21:36:39 -07:00
ric-yu
c0a649ef43 refactor: encapsulate error extraction in TaskItemImpl getters (#7650)
## Summary
- Add `errorMessage` and `executionError` getters to `TaskItemImpl` that
extract error info from status messages
- Update `useJobErrorReporting` composable to use these getters instead
of standalone function
- Remove the standalone `extractExecutionError` function

This encapsulates error extraction within `TaskItemImpl`, preparing for
the Jobs API migration where the underlying data format will change but
the getter interface will remain stable.

## Test plan
- [x] All existing tests pass
- [x] New tests added for `TaskItemImpl.errorMessage` and
`TaskItemImpl.executionError` getters
- [x] TypeScript, lint, and knip checks pass

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7650-refactor-encapsulate-error-extraction-in-TaskItemImpl-getters-2ce6d73d365081caae33dcc7e1e07720)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-15 21:11:22 -07:00
Comfy Org PR Bot
089295606a 1.38.3 (#8085)
Patch version increment to 1.38.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8085-1-38-3-2ea6d73d365081779d52dea9c48371a7)
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-15 18:14:27 -08:00
Christian Byrne
6048fab239 feat: add per-tab workspace authentication infrastructure (#8073)
## Summary
Add workspace authentication composables and types for per-tab workspace
isolation. This infrastructure enables users to work in different
workspaces in different browser tabs.

## Changes
- **useWorkspaceAuth composable** - workspace token management
- Exchange Firebase token for workspace-scoped JWT via `POST
/api/auth/token`
  - Auto-refresh tokens 5 minutes before expiry
  - Per-tab sessionStorage caching
- **useWorkspaceSwitch composable** - workspace switching with unsaved
changes confirmation
- **WorkspaceWithRole/WorkspaceTokenResponse types** - aligned with
backend API
- **firebaseAuthStore.getAuthHeader()** - prioritizes workspace tokens
over Firebase tokens
- **useSessionCookie** - uses Firebase token directly (getIdToken())
since getAuthHeader() now returns workspace token

## Backend Dependency
- `POST /api/auth/token` - exchange Firebase token for workspace token
- `GET /api/workspaces` - list user's workspaces

## Related
- https://github.com/Comfy-Org/ComfyUI_frontend/pull/6295

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8073-feat-add-per-tab-workspace-authentication-infrastructure-2e96d73d3650816c8cf9dae9c330aebb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: anthropic/claude <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2026-01-15 17:24:48 -08:00
AustinMroz
5409bf86a9 Make sure toggle visibility checks remote config (#8086)
The previous fix, with `.featureFlag()` does not actually result in
remoteConfig being checked

This time, I did extra manual testing to verify that remoteConfig is
populated on cloud and that the visibility of the toggle is actually
reactive to changes to remoteConfig.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8086-Make-sure-toggle-visibility-checks-remote-config-2ea6d73d36508182807bce3b0bb52868)
by [Unito](https://www.unito.io)
2026-01-15 16:11:53 -08:00
Johnpaul Chiwetelu
c56e8425d4 Road to No Explicit Any Part 6: Composables and Extensions (#8083)
## Summary
- Type `onExecuted` callbacks with `NodeExecutionOutput` in saveMesh.ts
and uploadAudio.ts
- Type composable parameters and return values properly
(useLoad3dViewer, useImageMenuOptions, useJobMenu, useResultGallery,
useContextMenuTranslation)
- Type `taskRef` as `TaskItemImpl` with updated test mocks
- Fix error catch and index signature patterns without `any`
- Add `NodeOutputWith<T>` generic helper for typed access to passthrough
properties on `NodeExecutionOutput`

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass for affected files
- [x] Sourcegraph checks confirm no external usage of modified types

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8083-Road-to-No-Explicit-Any-Part-6-Composables-and-Extensions-2e96d73d3650810fb033d745bf88a22b)
by [Unito](https://www.unito.io)
2026-01-16 00:27:28 +01:00
AustinMroz
0d5ca96a2b Further linear fixes (#8074)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8074-Further-linear-fixes-2e96d73d365081efb74bf150982c7a66)
by [Unito](https://www.unito.io)
2026-01-15 14:45:19 -08:00
Terry Jia
aff7f2a296 fix: prevent Record Audio waveform from overflowing node bounds (#8070)
## Summary

Add min-w-0 to flex containers to allow shrinking, reduce gaps and
padding, and use shrink-0 on fixed-size elements to ensure the waveform
area clips properly when the node is at minimum width.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/215455aa-555b-4ade-9b36-9e89ac7c14aa


after

https://github.com/user-attachments/assets/bf56028b-ae02-4388-be83-460c7b1f14e1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8070-fix-prevent-Record-Audio-waveform-from-overflowing-node-bounds-2e96d73d3650816b9660e5532ffa0bc3)
by [Unito](https://www.unito.io)
2026-01-15 15:49:32 -05:00
Alexander Brown
23694f37bf feat(ui): add TagsInput component with click-to-edit behavior (#8066)
## Summary

Add TagsInput component based on shadcn-vue/Reka UI primitives with a
click-to-edit UX pattern.

## Features

- **Click-to-edit behavior**: Starts in read-only state; clicking
enables editing and focuses input; clicking outside exits edit mode
- **Disabled state**: When `disabled=true`, component is completely
inert
- **Empty state placeholder**: Shows input placeholder when tag list is
empty
- **Animated transitions**: Delete button animates when toggling edit
mode

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-14 21:17:04 -08:00
pythongosssss
2466949192 Remove hamburger menu from tabs (#8067)
## Summary

We added the menu button to both the tabs & where the subgraph menu
button was previously in order to get feedback on where this button
should be. We've received feedback that the one on the tabs is not a
common UX element, and that having both seems like a bug, and that the
one on the graph is prefered. Due to this, we're removing the one on the
tabs.

## Changes

- Remove tab menu button

## Screenshots (if applicable)

Before:
<img width="733" height="224" alt="image"
src="https://github.com/user-attachments/assets/3f916d96-4bfe-482d-a8eb-8b18a7327334"
/>

After:
<img width="731" height="248" alt="image"
src="https://github.com/user-attachments/assets/5eeb31e5-e49f-409a-8eac-04773182a145"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8067-Remove-hamburger-menu-from-tabs-2e96d73d3650815aa80af4d5aa8767cd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-14 21:01:45 -08:00
Comfy Org PR Bot
f0702793bc 1.38.2 (#8057)
Patch version increment to 1.38.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8057-1-38-2-2e96d73d3650817d8d6ad4f40e043ad5)
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-14 21:00:17 -08:00
AustinMroz
efc242b968 Linear mode bug fixes (#8054)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8054-Linear-mode-bug-fixes-2e86d73d365081ed8d75d6e2af679f6c)
by [Unito](https://www.unito.io)
2026-01-14 20:49:13 -08:00
Andray
ed3c855eb6 fix: add missing pwa icon in manifest.json (#8071)
## Summary

Icon is required for PWA in most browsers. In the best case it hides the
install button inside "save and share" menu, and uses the favicon of low
quality or the first letter of the title for icon. In the worst case the
app is not installable

If you don't see the change in the browser - clear browser's cache. I
had an issue with it

## Changes

- **What**: added "icons" into "manifest.json"

## Screenshots

<img width="1087" height="556" alt="Screenshot_20260115_065548"
src="https://github.com/user-attachments/assets/61c13bb2-9230-4aae-a8ae-7f505d9a6bec"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8071-fix-add-missing-pwa-icon-in-manifest-json-2e96d73d3650818f9f10e79e4cc8cd5d)
by [Unito](https://www.unito.io)
2026-01-14 20:55:09 -07:00
Jin Yi
29f727a946 [bugfix] Fix bulk action context menu not emitting events (#8065) 2026-01-15 11:43:33 +09:00
Johnpaul Chiwetelu
538f007f1d Road to No Explicit Any Part 5: load3d Module (#8064)
## Summary
- Removes all `any` types from the load3d module
- Uses generics for EventCallback to provide type-safe event handling
- Types node parameters with LGraphNode
- Types onExecuted callback with NodeExecutionOutput
- Types Camera Config property casts with CameraConfig interface

## Changes
- `interfaces.ts`: EventCallback<T> generic, EventManagerInterface
generic methods
- `EventManager.ts`: Generic
emitEvent/addEventListener/removeEventListener
- `AnimationManager.ts`: setupModelAnimations originalModel typed as
GLTF union
- `Load3d.ts`: Event listener methods use EventCallback<T>
- `load3d.ts`: Node params typed as LGraphNode, onExecuted uses
NodeExecutionOutput, CameraConfig casts

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] `pnpm test:unit` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8064-Road-to-No-Explicit-Any-Part-5-load3d-Module-2e96d73d365081efbc01f2d8a4f3c11f)
by [Unito](https://www.unito.io)
2026-01-15 02:21:38 +01:00
Yourz
3069c24f81 feat: handling subscription tier button link parameter (#7553)
## Summary

Discussion here:
https://comfy-organization.slack.com/archives/C0A0XANFJRE/p1764899027465379

Implement: Subscription tier query parameter for direct checkout flow

Example button link: `/cloud/subscribe?tier=standard`

`tier` could be `standard`, `creator` or `pro`
`cycle` could be `monthly` or `yearly`. it is optional, and `monthly` by
default.

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- Add a landing page called `CloudSubscriptionRedirectView.vue` to
handling the subscription tier button link parameter
  - Extract subscription handling logic from `PriceTable.vue`
 
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
  - Code change touched  `PriceTable.vue`
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus
- link will redirect to login url, when cloud app not login
- after login, the cloud app will redirect to CloudSubscriptionRedirect
page
- wait for several seconds, the cloud app will be redirected to checkout
page

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

![Kapture 2025-12-16 at 18 43
28](https://github.com/user-attachments/assets/affbc18f-d45c-4953-b06a-fc797eba6804)


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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7553-feat-handling-subscription-tier-button-link-parameter-2cb6d73d365081ee9580e89090248300)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-14 17:57:51 -07:00
pythongosssss
97b1a48a25 Fix avatar using fallback icon not being square (#8060)
## Summary

When no user avatar image url exists / it fails to load, there is a
fallback to a user icon which does not render as a square in the tab bar
menu section.

## Changes
- set avatar to be square in compact mode

## Screenshots (if applicable)
Before:
<img width="193" height="37" alt="Screen Shot 2026-01-14 at 16 26 28"
src="https://github.com/user-attachments/assets/eb2e3723-4642-4184-9d0c-f7fb11b1a3ea"
/>

After:
<img width="201" height="37" alt="Screen Shot 2026-01-14 at 16 25 57"
src="https://github.com/user-attachments/assets/fb7bea2d-91cc-47e1-9076-4ec1a01ff923"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8060-Fix-avatar-using-fallback-icon-not-being-square-2e96d73d365081ea802bfbfdafdb1034)
by [Unito](https://www.unito.io)
2026-01-14 17:51:21 -07:00
Alexander Piskun
8497836811 feat(price-badges): add ByteDance SeeDance 1.5 prices (#8046)
## Summary

Added prices for `seedance-1-5-pro` model

## Screenshots (if applicable)

<img width="1540" height="891" alt="Screenshot From 2026-01-14 18-09-18"
src="https://github.com/user-attachments/assets/fefc6ec8-c5d0-40a4-bede-55076376128f"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8046-feat-price-badges-add-ByteDance-SeeDance-1-5-prices-2e86d73d3650814cb988dc94cfb48993)
by [Unito](https://www.unito.io)
2026-01-14 17:14:12 -07:00
Christian Byrne
946429f2ff fix: use staging platform URL for usage history link (#8056)
## Summary

Uses `getComfyPlatformBaseUrl()` for the usage history link so it
correctly points to `stagingplatform.comfy.org` on staging builds
instead of being hardcoded to `platform.comfy.org`.

## Changes

- Added `usageHistoryUrl` computed that uses `getComfyPlatformBaseUrl()`
- Updated template to use dynamic `:href` binding

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8056-fix-use-staging-platform-URL-for-usage-history-link-2e86d73d36508186a845ffd84e5caaf2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-14 17:13:05 -07:00
Alexander Brown
3d332ff0d7 devex: Silence warning for misused spread in test. (#8055)
## Summary

Silences this warning:
```
  ⚠ typescript-eslint(no-misused-spread): Using the spread operator on class instances will lose their class prototype.
     ╭─[src/composables/maskeditor/useCanvasHistory.test.ts:171:9]
 170 │       mockRefs.maskCanvas = {
 171 │         ...mockRefs.maskCanvas,
     ·         ──────────────────────
 172 │         width: 0,
     ╰────
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8055-devex-Silence-warning-for-misused-spread-in-test-2e86d73d365081659d1fdbc61675a532)
by [Unito](https://www.unito.io)
2026-01-14 15:45:46 -08:00
Terry Jia
45d95728f3 fix: preserve image preview when backend returns null output (#8050)
## Summary

When two LoadImage nodes select the same image, the backend's cache
mechanism returns null output for the second node (same cache
signature). This was overwriting the existing image preview data.

Now skip setting outputs when null/undefined to preserve the preview.

Root cause chain
07f2462eae/execution.py (L421):

1. When two LoadImage nodes select the same image, they have identical
cache signatures (based on IS_CHANGED SHA256 hash + input parameters)
2. First node executes: Actually runs load_image(), caches the result.
But LoadImage's ui field is None (it only produces tensors, no UI
output)
3. Second node executes: Cache hit, directly returns
cached_ui.get("output", None) = null
  4. Frontend receives null and overwrites the existing image preview

## Screenshots (if applicable)
Before


https://github.com/user-attachments/assets/7bd814f6-bf23-42cc-9fc3-fd9fec68b4f6


After


https://github.com/user-attachments/assets/b9cc6160-ea70-424e-8a3d-5dc9f244d0d0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8050-fix-preserve-image-preview-when-backend-returns-null-output-2e86d73d36508156ab32cb12a7f6b307)
by [Unito](https://www.unito.io)
2026-01-14 15:28:20 -05:00
Alexander Piskun
81c66822f5 [API Nodes] add price badges for Meshy 3D nodes (#7966)
## Summary

Prices were taken from here: https://docs.meshy.ai/en/api/pricing

<img width="1422" height="771" alt="Screenshot From 2026-01-12 19-16-58"
src="https://github.com/user-attachments/assets/df9d6024-b323-41bf-920c-e329a4f3344d"
/>

<img width="1422" height="771" alt="Screenshot From 2026-01-12 19-17-18"
src="https://github.com/user-attachments/assets/80e8837c-db39-441d-bab3-609f63645cb5"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7966-API-Nodes-add-price-badges-for-Meshy-3D-nodes-2e66d73d365081b4843bf6a2f816b9e0)
by [Unito](https://www.unito.io)
2026-01-14 12:49:25 -07:00
Alexander Brown
3bb8e94247 Cleanup: Remove i18n CODEOWNERS entries (#8044)
## Summary

So far has mostly added noise to PRs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8044-Cleanup-Remove-i18n-CODEOWNERS-entries-2e86d73d365081c7ac05ef7ca8c7f03e)
by [Unito](https://www.unito.io)
2026-01-14 10:25:13 -08:00
Jin Yi
6382b1e099 feat: add bulk actions for workflow operations in media assets (#7992)
## Summary

Add bulk action support for Add to Workflow, Open Workflow, and Export
Workflow when multiple assets are selected.

## Changes

- **What**: Bulk operations for Add to Workflow, Open/Export Workflow in
context menu

## Review Focus

- Node positioning: Multiple nodes created at same canvas center
position (may overlap)
- Context menu item ordering without separators

<img width="1927" height="921" alt="스크린샷 2026-01-13 오후 12 54 52"
src="https://github.com/user-attachments/assets/6f079232-1b24-4f02-810f-6e396916bb71"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7992-feat-add-bulk-actions-for-workflow-operations-in-media-assets-2e76d73d365081aa90c6fdb5039c9a3e)
by [Unito](https://www.unito.io)
2026-01-13 21:24:13 -07:00
AustinMroz
25afd39d2b linear v2: Simple Mode (#7734)
A major, full rewrite of linear mode, now under the name "Simple Mode". 
- Fixes widget styling
- Adds a new simplified history
- Adds support for non-image outputs
- Supports right sidebar
- Allows and panning on the output image preview
- Provides support for drag and drop zones
- Moves workflow notes into a popover.
- Allows scrolling through outputs with Ctrl+scroll or arrow keys

The primary means of accessing Simple Mode is a toggle button on the
bottom right. This button is only shown if a feature flag is enabled, or
the user has already seen linear mode during the current session. Simple
Mode can also be accessed by
- Using the toggle linear mode keybind
- Loading a workflow that that was saved in Simple Mode workflow
- Loading a template url with appropriate parameter

<img width="1790" height="1387" alt="image"
src="https://github.com/user-attachments/assets/d86a4a41-dfbf-41e7-a6d9-146473005606"
/>

Known issues:
- Outputs on cloud are not filtered to those produced by the current
workflow.
  - Output filtering has been globally disabled for consistency
- Outputs will load more items on scroll, but does not unload
- Performance may be reduced on weak devices with very large histories.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7734-linear-v2-2d16d73d3650819b8a10f150ff12ea22)
by [Unito](https://www.unito.io)
2026-01-13 20:18:31 -08:00
ric-yu
773f5f5cd9 fix: properly type extractWorkflow return (#7646)
## Summary
Type `extractWorkflow` return as `ComfyWorkflowJSON | undefined` instead
of `unknown` for better type safety downstream.

## Context
This is a small preparatory PR to slim down the Jobs API migration PR
(PR 2 of 3).

## Test plan
- [x] TypeCheck passes
- [x] Lint passes

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7646-fix-properly-type-extractWorkflow-return-2ce6d73d365081c794eac3669632271f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-13 21:17:28 -07:00
pythongosssss
ebca0cb1e0 Fix vue node slot position calculation (#7877)
## Summary

Fix slot position for Vue nodes using LiteGraph calculation

Fixes #7446

## Changes
- Updated getInput/OutputPos to call getSlotPosition which handles
positions for both LiteGraph & Vue nodes correctly
- Add regression test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7877-Fix-vue-node-slot-position-calculation-2e16d73d365081219afef0e4ebfb0620)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-13 20:40:21 -07:00
Rizumu Ayaka
b1b2fd8a4f feat: right side panel favorites, no selection state, and more... (#7812)
Most of the features in this pull request are completed and can be
reviewed and merged.

## TODO

- [x] no selection panel
- [x] group selected panel
- [x] tabs 
  - [x] favorites tab
  - [x] global settings tab
  - [x] nodes tab
- [x] widget actions menu 
  - [x] [Bug]: style bugs
- [x] button zoom to the node on canvas.
- [x] rename widgets on widget actions
  - [ ] [Bug]: the canvas has not been updated after renaming. 
- [x] global settings
  - [ ] setting item: "show advanced parameters"
    - blocked by other things. skip for now.
  - [x] setting item: show toolbox on selection 
  - [x] setting item: nodes 2.0
  - [ ] setting item: "background color"
    - blocked by other things. skip for now.
  - [x] setting item: grid spacing
  - [x] setting item: snap nodes to grid
  - [x] setting item: link shape
  - [x] setting item: show connected links
  - [x] form style reuses the form style of node widgets
- [x] group node cases
  - [x] group node settings
  - [x] show all nodes in group
  - [x] show frame name on nodes when multiple selections are made
  - [x] group multiple selections
- [x] [Bug]: nodes without widgets cannot display the location and their
group
  - [x] [Bug]: labels layout
- [x] favorites
  - [x] the indicator on widgets
  - [x] favorite and unfavorite buttons on widgets
- [x] [Bug]: show node name in favorite widgets + improve labels layout
- [ ] [Bug]: After canceling the like, the like list will not be updated
immediately.
- [x] [Bug]: The favorite function does not work for the project on
Subgraph.
- [x] subgraph
- [x] add the node name from where this parameter comes from when node
is subgraph
  - [x] show and hide directly on Inputs
    - [x] some bugs need to be fixed.
- [x] advanced widgets 
  - [x] button: show advanced inputs
- Clicking button expands the "Advanced Inputs" section on the right
side panel, regardless of whether the panel is open or not
    - [x] [Bug]: style bugs
  - [x] advanced inputs section when node is subgraph
- [x] inputs tab rearranging
  - [x] favorited inputs rearranging
  - [x] subgraph inputs rearranging
- [ ] review and reconstruction to improve complexity and architecture

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7812-feat-right-side-panel-favorites-no-selection-state-and-more-2da6d73d36508134b503d676f9b3d248)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-01-13 20:37:17 -07:00
Christian Byrne
069e94b325 fix: version mismatch warning appearing in Playwright tests despite DisableWarnings setting (#8036)
PR #7004 added a setting to disable version warnings in e2e tests, but
it wasn't working on release branches. The issue was a race condition
(hypothesis): the version check ran before settings finished loading
from the backend, so the DisableWarnings setting read its default value
(false) instead of the configured value (true).

Fixed by making the warningsDisabled check reactive so it updates when
settings load and adding `nextTick` (settings are loaded, but ref
updates flush in a microtask. The immediate `whenever` runs before that
flush, so computeds may see stale/default values -- `nextTick` waits for
reactive microtasks to flush, so computeds will be correct. It's fine).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8036-fix-version-mismatch-warning-appearing-in-Playwright-tests-despite-DisableWarnings-setti-2e86d73d36508132b4d1fd73ade76e63)
by [Unito](https://www.unito.io)
2026-01-13 18:28:33 -08:00
Christian Byrne
2901e1e403 fix: Update repository reference comfyanonymous/ComfyUI → Comfy-Org/ComfyUI (#8037)
Updates repository reference after the official rename. GitHub API calls
don't follow redirects, which can break CI workflows using
actions/checkout with the old repository name.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8037-fix-Update-repository-reference-comfyanonymous-ComfyUI-Comfy-Org-ComfyUI-2e86d73d3650816f8c37c7ec3058817c)
by [Unito](https://www.unito.io)
2026-01-13 18:55:06 -07:00
Johnpaul Chiwetelu
3412a0908d Road to No Explicit Any Part 4: LiteGraph Cleanup (#7970)
## Summary
- Remove all remaining `any` types from LiteGraph module
- Define proper `Panel` interface for createPanel function and related
callbacks
- Type `SlotTypeDefaultNodeOpts` for slot type defaults configuration
- Fix type inference issues in sendActionToCanvas and contextMenuCompat

## Changes
- **SubgraphIONodeBase.ts**: Remove unnecessary `as any` cast in
showSlotContextMenu
- **interfaces.ts**: Add Panel, PanelButton, PanelWidget,
PanelWidgetOptions, PanelWidgetCallback types
- **LGraphNode.ts**: Type panel callbacks (onShowCustomPanelInfo,
onAddPropertyToPanel) and onGetPropertyInfo
- **LGraphCanvas.ts**: 
  - Type node_panel and options_panel as Panel
  - Type inner_clicked callback parameters
- Type local variables (nodeNewType, nodeNewOpts, prevent_timeout, iS,
sIn, sOut)
  - Add SlotTypeDefaultNodeOpts interface for slot type configuration
- **LGraph.ts**: Fix sendActionToCanvas type inference with proper array
handling
- **contextMenuCompat.ts**: Add null guard for newImpl parameter

## Test plan
- [x] pnpm typecheck passes
- [x] pnpm lint passes
- [x] knip passes (no unused exports)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7970-Road-to-No-Explicit-Any-Part-4-LiteGraph-Cleanup-2e66d73d3650812c939bf9b2cb0ff2f5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-14 02:49:12 +01:00
Comfy Org PR Bot
81df0102f8 1.38.1 (#8035)
Patch version increment to 1.38.1

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-13 17:44:31 -07:00
Benjamin Lu
84662cb94c [QPOv2] Add ... context menu to list view (#7745)
Add ... context menu to list view

This is the same ... context menu used in the grid view, now moved up to
the tab scope so it can be shared between views.

Part of the QPO v2 iteration, figma design can be found
[here](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3330-37286&m=dev).
This will be implemented in a series of stacked PRs that can be reviewed
and merged individually.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7745-QPOv2-Add-context-menu-to-list-view-2d26d73d365081329a11ce97472bbf87)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-13 17:21:06 -07:00
Jin Yi
eb213d0ad3 feat: add overflow tooltip to NavItem (#8009)
## Summary
- Add text truncation with ellipsis to NavItem when text overflows
- Show tooltip on hover only when text is truncated
- Use on-demand overflow check (mouseenter) instead of ResizeObserver
for better performance

## Test plan
- [x] Verify NavItem text truncates with ellipsis when it overflows
- [x] Verify tooltip appears on hover only when text is truncated
- [x] Verify tooltip does not appear when text fits without truncation

<img width="1517" height="1028" alt="스크린샷 2026-01-13 오후 2 20 20"
src="https://github.com/user-attachments/assets/3b531785-a567-4e87-9540-fcbab0fe1de6"
/>

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8009-feat-add-overflow-tooltip-to-NavItem-2e76d73d3650815a9d7cefb3f0bf2daa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-13 16:14:23 -08:00
Alexander Brown
971316205f Fix(test): Mask username field in settings since it's not deterministic. (#8033)
## Summary

Should stop this from flipping back and forth depending on which user
the harness ends up with.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8033-Fix-test-Mask-username-field-in-settings-since-it-s-not-deterministic-2e76d73d3650811e85f4fc4ca2253e7e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-13 15:44:45 -08:00
AustinMroz
20d06f92ca Fix error on close of combo dropdown (#7804)
Apparently, enabling autofocus causes primevue to also attempt focusing
the searchbox one frame after closing.

I have no idea why this behaviour would ever be beneficial, but this PR
adds an override to prevent this behaviour and clean up the console
spam.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7804-Fix-error-on-close-of-combo-dropdown-2d96d73d365081f8b677e3e4aaadb51b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-13 23:31:55 +00:00
AustinMroz
97a78f4a35 Implement vue math (#7759)
Adds support for entering math inside number widgets in vue mode

![vue-math_00001](https://github.com/user-attachments/assets/e8ed7e2a-511f-4550-bfdd-1c467972d30d)

Migrates components to simple html elements (div and button) by
borrowing styling from the (reverted) reka-ui migration in #6985. The
existing (evil) litegraph eval code is extracted as a utility function
and reused.

This PR means we're entirely writing our own NumberField.

Also adds support for scrubbing widgets like in litegraph

![scrubbing_00001](https://github.com/user-attachments/assets/890bcd28-34f4-4be0-908f-657e43f671b0)

### Known Issue
- Scrubbing causes text to be highlighted, ~~starting a scrub from
highlighted text will instead drag the text~~.
- It seems this can only be prevented with `pointerdown.prevent`, but
this requires a manual `input.focus()` which does not place the cursor
at location of mouse click.

(Obligatory: _It won't do you a bit of good to review math_)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7759-Implement-vue-math-2d46d73d365081b9acd4d6422669016e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-01-13 15:11:33 -08:00
Alexander Brown
4b2e4c59af Fix: Update for Image Widget test (#8031)
...

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8031-Fix-Update-for-Image-Widget-test-2e76d73d365081daa35ce81e7c11baee)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-13 14:46:41 -08:00
Johnpaul Chiwetelu
6cbb83a1e2 perf(ci): remove unnecessary pnpm install from merge-reports job (#8030)
## Summary
- Remove `setup-frontend` action from `merge-reports` job
- Use `npx @playwright/test` instead of `pnpm exec playwright`

## Why
The `merge-reports` job was spending ~16-18s on `pnpm install` just to
run a CLI command that takes ~3s. Since `npx` is pre-installed on GitHub
runners, we can eliminate the setup overhead entirely.

**Expected savings: ~16-18 seconds per CI run**

## Test Plan
- [ ] Verify merge-reports job completes successfully
- [ ] Verify HTML report is generated and uploaded correctly
- [ ] Compare job timing before/after

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8030-perf-ci-remove-unnecessary-pnpm-install-from-merge-reports-job-2e76d73d36508134b3e6c11726170f64)
by [Unito](https://www.unito.io)
2026-01-13 22:43:32 +01:00
Terry Jia
14866ac11b feat: rename Vue-only widget label to Node 2.0 only (#8025)
## Summary

Add i18n key widgets.node2only and use it in litegraph widgets that only
support vueNodes mode.

## Screenshots (if applicable)
<img width="534" height="288" alt="image"
src="https://github.com/user-attachments/assets/e0987ec7-af09-4885-adf1-0c5cd6cba356"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8025-feat-rename-Vue-only-widget-label-to-Node-2-0-only-2e76d73d3650812cb7baf763440e2452)
by [Unito](https://www.unito.io)
2026-01-13 14:31:19 -07:00
Jin Yi
6f3855f5f1 fix: reduce media asset card hover background opacity for button visibility (#8020)
## Summary
Reduces the MediaAssetCard hover background opacity to 20% so buttons
within the card stand out during hover interactions.

## Changes
- **What**: Changed `hover:bg-modal-card-background-hovered` to
`hover:bg-modal-card-background-hovered/20` in MediaAssetCard.vue

## Context
During design review, it was identified that the button hover color
matches the card hover color, making it difficult to distinguish button
hover states. This fix applies reduced opacity to the card background
hover, ensuring buttons remain visually distinct.

<img width="659" height="302" alt="스크린샷 2026-01-13 오후 2 39 40"
src="https://github.com/user-attachments/assets/a77b408e-46a5-4725-9afb-2260bda0f8c9"
/>

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8020-fix-reduce-media-asset-card-hover-background-opacity-for-button-visibility-2e76d73d365081b1afcefe17ac6db6ac)
by [Unito](https://www.unito.io)
2026-01-12 23:49:10 -07:00
Comfy Org PR Bot
0f1e54530c 1.38.0 (#8021)
Minor version increment to 1.38.0

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-12 22:56:51 -07:00
457 changed files with 18211 additions and 16628 deletions

View File

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

View File

@@ -1,21 +0,0 @@
---
description: Creating unit tests
globs:
alwaysApply: false
---
# Creating unit tests
- This project uses `vitest` for unit testing
- Tests are stored in the `test/` directory
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
- Tests should be mocked properly
- Mocks should be cleanly written and easy to understand
- Mocks should be re-usable where possible
## Unit test style
- Prefer the use of `test.extend` over loose variables
- To achieve this, import `test as baseTest` from `vitest`
- Never use `it`; `test` should be used in place of this

14
.github/AGENTS.md vendored Normal file
View File

@@ -0,0 +1,14 @@
# PR Review Context
Context for automated PR review system.
## Review Scope
This automated review performs comprehensive analysis:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.

39
.github/CLAUDE.md vendored
View File

@@ -1,36 +1,3 @@
# ComfyUI Frontend - Claude Review Context
This file provides additional context for the automated PR review system.
## Quick Reference
### PrimeVue Component Migrations
When reviewing, flag these deprecated components:
- `Dropdown` → Use `Select` from 'primevue/select'
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
- `TabMenu` → Use `Tabs` without panels
- `Steps` → Use `Stepper` without panels
- `InlineMessage` → Use `Message` component
### API Utilities Reference
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
- `api.fileURL()` - Static file access (templates, extensions)
- `$t()` / `i18n.global.t()` - Internationalization
- `DOMPurify.sanitize()` - HTML sanitization
## Review Scope
This automated review performs comprehensive analysis including:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
<!-- A rose by any other name would smell as sweet,
But Claude insists on files named for its own conceit. -->
@AGENTS.md

View File

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

View File

@@ -144,9 +144,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
# Setup pnpm/node to run playwright merge-reports (no browsers needed)
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Download blob reports
uses: actions/download-artifact@v4
@@ -158,10 +159,10 @@ jobs:
- name: Merge into HTML Report
run: |
# Generate HTML report
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4

View File

@@ -69,7 +69,7 @@ jobs:
- name: Checkout ComfyUI (sparse)
uses: actions/checkout@v5
with:
repository: comfyanonymous/ComfyUI
repository: Comfy-Org/ComfyUI
sparse-checkout: |
requirements.txt
path: comfyui
@@ -184,7 +184,7 @@ jobs:
# Note: This only affects the local checkout, NOT the fork's master branch
# We only push the automation branch, leaving the fork's master untouched
echo "Fetching upstream master..."
if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
if ! git fetch https://github.com/Comfy-Org/ComfyUI.git master; then
echo "Failed to fetch upstream master"
exit 1
fi
@@ -257,7 +257,7 @@ jobs:
# Extract fork owner from repository name
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
echo "Creating PR from ${COMFYUI_FORK} to Comfy-Org/ComfyUI"
# Configure git
git config user.name "github-actions[bot]"
@@ -288,7 +288,7 @@ jobs:
# Try to create PR, ignore error if it already exists
if ! gh pr create \
--repo comfyanonymous/ComfyUI \
--repo Comfy-Org/ComfyUI \
--head "${FORK_OWNER}:${BRANCH}" \
--base master \
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
@@ -297,7 +297,7 @@ jobs:
# Check if PR already exists
set +e
EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
EXISTING_PR=$(gh pr list --repo Comfy-Org/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
PR_LIST_EXIT=$?
set -e
@@ -318,7 +318,7 @@ jobs:
run: |
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
echo "Draft PR created in Comfy-Org/ComfyUI" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
cat pr-body.txt >> $GITHUB_STEP_SUMMARY

View File

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

20
.oxfmtrc.json Normal file
View File

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

View File

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

View File

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

17
.storybook/AGENTS.md Normal file
View File

@@ -0,0 +1,17 @@
# Storybook Guidelines
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
## Available Context
Stories have access to:
- All ComfyUI stores
- PrimeVue with ComfyUI theming
- i18n system
- CSS variables and styling
## Troubleshooting
1. **Import Errors**: Verify `@/` alias works
2. **Missing Styles**: Check CSS imports in `preview.ts`
3. **Store Errors**: Check store initialization in setup

View File

@@ -1,197 +1,3 @@
# Storybook Development Guidelines for Claude
## Quick Commands
- `pnpm storybook`: Start Storybook development server
- `pnpm build-storybook`: Build static Storybook
- `pnpm test:unit`: Run unit tests (includes Storybook components)
## Development Workflow for Storybook
1. **Creating New Stories**:
- Place `*.stories.ts` files alongside components
- Follow the naming pattern: `ComponentName.stories.ts`
- Use realistic mock data that matches ComfyUI schemas
2. **Testing Stories**:
- Verify stories render correctly in Storybook UI
- Test different component states and edge cases
- Ensure proper theming and styling
3. **Code Quality**:
- Run `pnpm typecheck` to verify TypeScript
- Run `pnpm lint` to check for linting issues
- Follow existing story patterns and conventions
## Story Creation Guidelines
### Basic Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: {
layout: 'centered' // or 'fullscreen', 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
// Component props
}
}
```
### Mock Data Patterns
For ComfyUI components, use realistic mock data:
```typescript
// Node definition mock
const mockNodeDef = {
input: {
required: {
prompt: ["STRING", { multiline: true }]
}
},
output: ["CONDITIONING"],
output_is_list: [false],
category: "conditioning"
}
// Component instance mock
const mockComponent = {
id: "1",
type: "CLIPTextEncode",
// ... other properties
}
```
### Common Story Variants
Always include these story variants when applicable:
- **Default**: Basic component with minimal props
- **WithData**: Component with realistic data
- **Loading**: Component in loading state
- **Error**: Component with error state
- **LongContent**: Component with edge case content
- **Empty**: Component with no data
### Storybook-Specific Code Patterns
#### Store Access
```typescript
// In stories, access stores through the setup function
export const WithStore: Story = {
render: () => ({
setup() {
const store = useMyStore()
return { store }
},
template: '<MyComponent :data="store.data" />'
})
}
```
#### Event Testing
```typescript
export const WithEvents: Story = {
args: {
onUpdate: fn() // Use Storybook's fn() for action logging
}
}
```
## Configuration Notes
### Vue App Setup
The Storybook preview is configured with:
- Pinia stores initialized
- PrimeVue with ComfyUI theme
- i18n internationalization
- All necessary CSS imports
### Build Configuration
- Vite integration with proper alias resolution
- Manual chunking for better performance
- TypeScript support with strict checking
- CSS processing for Vue components
## Troubleshooting
### Common Issues
1. **Import Errors**: Verify `@/` alias is working correctly
2. **Missing Styles**: Ensure CSS imports are in `preview.ts`
3. **Store Errors**: Check store initialization in setup
4. **Type Errors**: Use proper TypeScript types for story args
### Debug Commands
```bash
# Check TypeScript issues
pnpm typecheck
# Lint Storybook files
pnpm lint .storybook/
# Build to check for production issues
pnpm build-storybook
```
## File Organization
```
.storybook/
├── main.ts # Core configuration
├── preview.ts # Global setup and decorators
├── README.md # User documentation
└── CLAUDE.md # This file - Claude guidelines
src/
├── components/
│ └── MyComponent/
│ ├── MyComponent.vue
│ └── MyComponent.stories.ts
```
## Integration with ComfyUI
### Available Context
Stories have access to:
- All ComfyUI stores (widgetStore, colorPaletteStore, etc.)
- PrimeVue components with ComfyUI theming
- Internationalization system
- ComfyUI CSS variables and styling
### Testing Components
When testing ComfyUI-specific components:
1. Use realistic node definitions and data structures
2. Test with different node types (sampling, conditioning, etc.)
3. Verify proper CSS theming and dark/light modes
4. Check component behavior with various input combinations
### Performance Considerations
- Use manual chunking for large dependencies
- Minimize bundle size by avoiding unnecessary imports
- Leverage Storybook's lazy loading capabilities
- Profile build times and optimize as needed
## Best Practices
1. **Keep Stories Focused**: Each story should demonstrate one specific use case
2. **Use Descriptive Names**: Story names should clearly indicate what they show
3. **Document Complex Props**: Use JSDoc comments for complex prop types
4. **Test Edge Cases**: Create stories for unusual but valid use cases
5. **Maintain Consistency**: Follow established patterns in existing stories
<!-- Though standards bloom in open fields so wide,
Anthropic walks a path of lonely pride. -->
@AGENTS.md

View File

@@ -96,15 +96,15 @@ const config: StorybookConfig = {
}
]
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

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

View File

@@ -1,5 +1,7 @@
# Repository Guidelines
See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob).
## Project Structure & Module Organization
- Source: `src/`
@@ -25,10 +27,10 @@
- Build output: `dist/`
- Configs
- `vite.config.mts`
- `vitest.config.ts`
- `playwright.config.ts`
- `eslint.config.ts`
- `.prettierrc`
- `.oxfmtrc.json`
- `.oxlintrc.json`
- etc.
## Monorepo Architecture
@@ -44,8 +46,23 @@ The project uses **Nx** for build orchestration and task management
- `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: Prettier
- `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm typecheck`: Vue TSC type checking
- `pnpm storybook`: Start Storybook development server
## Development Workflow
1. Make code changes
2. Run relevant tests
3. Run `pnpm typecheck`, `pnpm lint`, `pnpm format`
4. Check if README updates are needed
5. Suggest docs.comfy.org updates for user-facing changes
## Git Conventions
- Use `prefix:` format: `feat:`, `fix:`, `test:`
- Add "Fixes #n" to PR descriptions
- Never mention Claude/AI in commits
## Coding Style & Naming Conventions
@@ -55,7 +72,7 @@ The project uses **Nx** for build orchestration and task management
- Composition API only
- Tailwind 4 styling
- Avoid `<style>` blocks
- Style: (see `.prettierrc`)
- Style: (see `.oxfmtrc.json`)
- Indent 2 spaces
- single quotes
- no trailing semicolons

View File

@@ -1,30 +1 @@
# Claude Code specific instructions
@Agents.md
## Repository Setup
For first-time setup, use the Claude command:
```sh
/setup_repo
```
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc.
## Development Workflow
1. **First-time setup**: Run `/setup_repo` Claude command
2. Make code changes
3. Run tests (see subdirectory CLAUDE.md files)
4. Run typecheck, lint, format
5. Check README updates
6. Consider docs.comfy.org updates
## Git Conventions
- Use `prefix:` format: `feat:`, `fix:`, `test:`
- Add "Fixes #n" to PR descriptions
- Never mention Claude/AI in commits
@AGENTS.md

View File

@@ -37,7 +37,7 @@
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
@@ -55,8 +55,7 @@
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -64,7 +64,7 @@ export default defineConfig(() => {
})
],
build: {
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
minify: SHOULD_MINIFY,
target: 'es2022',
sourcemap: true
}

8
browser_tests/AGENTS.md Normal file
View File

@@ -0,0 +1,8 @@
# E2E Testing Guidelines
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
## Directory Structure
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state

View File

@@ -1,17 +1,3 @@
# E2E Testing Guidelines
## Browser Tests
- Test user workflows
- Use Playwright fixtures
- Follow naming conventions
## Best Practices
- Check assets/ for test data
- Prefer specific selectors
- Test across viewports
## Testing Process
After code changes:
1. Create browser tests as appropriate
2. Run tests until passing
3. Then run typecheck, lint, format
<!-- In gardens where the agents freely play,
One stubborn flower turns the other way. -->
@AGENTS.md

View File

@@ -3,7 +3,7 @@ import { test as base, expect } from '@playwright/test'
import dotenv from 'dotenv'
import * as fs from 'fs'
import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph'
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
@@ -21,7 +21,6 @@ import {
import { Topbar } from './components/Topbar'
import type { Position, Size } from './types'
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
import TaskHistory from './utils/taskHistory'
dotenv.config()
@@ -146,8 +145,6 @@ class ConfirmDialog {
}
export class ComfyPage {
private _history: TaskHistory | null = null
public readonly url: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
@@ -301,11 +298,6 @@ export class ComfyPage {
}
}
setupHistory(): TaskHistory {
this._history ??= new TaskHistory(this)
return this._history
}
async setup({
clearStorage = true,
mockReleases = true
@@ -1591,14 +1583,29 @@ export class ComfyPage {
return window['app'].graph.nodes
})
}
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
async waitForGraphNodes(count: number) {
await this.page.waitForFunction((count) => {
return window['app']?.canvas.graph?.nodes?.length === count
}, count)
}
async getNodeRefsByType(
type: string,
includeSubgraph: boolean = false
): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate((type) => {
return window['app'].graph.nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
}, type)
await this.page.evaluate(
({ type, includeSubgraph }) => {
const graph = (
includeSubgraph ? window['app'].canvas.graph : window['app'].graph
) as LGraph
const nodes = graph.nodes
return nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
},
{ type, includeSubgraph }
)
).map((id: NodeId) => this.getNodeRefById(id))
)
}

View File

@@ -159,8 +159,18 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').nth(1)
decrementButton: widget.getByTestId('decrement'),
incrementButton: widget.getByTestId('increment')
}
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId('subgraph-enter-button')
await editButton.click()
}
}

View File

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

View File

@@ -1,164 +0,0 @@
import type { Request, Route } from '@playwright/test'
import _ from 'es-toolkit/compat'
import fs from 'fs'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
import type {
HistoryTaskItem,
TaskItem,
TaskOutput
} from '../../../src/schemas/apiSchema'
import type { ComfyPage } from '../ComfyPage'
/** keyof TaskOutput[string] */
type OutputFileType = 'images' | 'audio' | 'animated'
const DEFAULT_IMAGE = 'example.webp'
const getFilenameParam = (request: Request) => {
const url = new URL(request.url())
return url.searchParams.get('filename') || DEFAULT_IMAGE
}
const getContentType = (filename: string, fileType: OutputFileType) => {
const subtype = path.extname(filename).slice(1)
switch (fileType) {
case 'images':
return `image/${subtype}`
case 'audio':
return `audio/${subtype}`
case 'animated':
return `video/${subtype}`
}
}
const setQueueIndex = (task: TaskItem) => {
task.prompt[0] = TaskHistory.queueIndex++
}
const setPromptId = (task: TaskItem) => {
task.prompt[1] = uuidv4()
}
export default class TaskHistory {
static queueIndex = 0
static readonly defaultTask: Readonly<HistoryTaskItem> = {
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
outputs: {},
status: {
status_str: 'success',
completed: true,
messages: []
},
taskType: 'History'
}
private tasks: HistoryTaskItem[] = []
private outputContentTypes: Map<string, string> = new Map()
constructor(readonly comfyPage: ComfyPage) {}
private loadAsset: (filename: string) => Buffer = _.memoize(
(filename: string) => {
const filePath = this.comfyPage.assetPath(filename)
return fs.readFileSync(filePath)
}
)
private async handleGetHistory(route: Route) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.tasks)
})
}
private async handleGetView(route: Route) {
const fileName = getFilenameParam(route.request())
if (!this.outputContentTypes.has(fileName)) {
return route.continue()
}
const asset = this.loadAsset(fileName)
return route.fulfill({
status: 200,
contentType: this.outputContentTypes.get(fileName),
body: asset,
headers: {
'Cache-Control': 'public, max-age=31536000',
'Content-Length': asset.byteLength.toString()
}
})
}
async setupRoutes() {
return this.comfyPage.page.route(
/.*\/api\/(view|history)(\?.*)?$/,
async (route) => {
const request = route.request()
const method = request.method()
const isViewReq = request.url().includes('view') && method === 'GET'
if (isViewReq) return this.handleGetView(route)
const isHistoryPath = request.url().includes('history')
const isGetHistoryReq = isHistoryPath && method === 'GET'
if (isGetHistoryReq) return this.handleGetHistory(route)
const isClearReq =
method === 'POST' &&
isHistoryPath &&
request.postDataJSON()?.clear === true
if (isClearReq) return this.clearTasks()
return route.continue()
}
)
}
private createOutputs(
filenames: string[],
filetype: OutputFileType
): TaskOutput {
return filenames.reduce((outputs, filename, i) => {
const nodeId = `${i + 1}`
outputs[nodeId] = {
[filetype]: [{ filename, subfolder: '', type: 'output' }]
}
const contentType = getContentType(filename, filetype)
this.outputContentTypes.set(filename, contentType)
return outputs
}, {})
}
private addTask(task: HistoryTaskItem) {
setPromptId(task)
setQueueIndex(task)
this.tasks.unshift(task) // Tasks are added to the front of the queue
}
clearTasks(): this {
this.tasks = []
return this
}
withTask(
outputFilenames: string[],
outputFiletype: OutputFileType = 'images',
overrides: Partial<HistoryTaskItem> = {}
): this {
this.addTask({
...TaskHistory.defaultTask,
outputs: this.createOutputs(outputFilenames, outputFiletype),
...overrides
})
return this
}
/** Repeats the last task in the task history a specified number of times. */
repeat(n: number): this {
for (let i = 0; i < n; i++)
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
return this
}
}

View File

@@ -22,8 +22,14 @@ test.describe('Mobile Baseline Snapshots', () => {
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png'
'mobile-settings-dialog.png',
{
mask: [
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
]
}
)
})
})

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: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -8,13 +8,11 @@ test.describe('Properties panel', () => {
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText(
'No item(s) selected'
)
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
await expect(propertiesPanel.panelTitle).toContainText('3 nodes selected')
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 103 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: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

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

View File

@@ -102,7 +102,7 @@ test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
// await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
@@ -993,4 +993,51 @@ test.describe('Vue Node Link Interaction', () => {
expect(linked).toBe(true)
})
})
test('Dragging from subgraph input connects to correct slot', async ({
comfyPage,
comfyMouse
}) => {
// Setup workflow with a KSampler node
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.waitForGraphNodes(0)
await comfyPage.executeCommand('Workspace.SearchBox.Toggle')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await comfyPage.waitForGraphNodes(1)
// Convert the KSampler node to a subgraph
let ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler'))?.[0]
await comfyPage.vueNodes.selectNode(String(ksamplerNode.id))
await comfyPage.executeCommand('Comfy.Graph.ConvertToSubgraph')
// Enter the subgraph
await comfyPage.vueNodes.enterSubgraph()
await fitToViewInstant(comfyPage)
// Get the KSampler node inside the subgraph
ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler', true))?.[0]
const positiveInput = await ksamplerNode.getInput(1)
const negativeInput = await ksamplerNode.getInput(2)
const positiveInputPos = await getSlotCenter(
comfyPage.page,
ksamplerNode.id,
1,
true
)
const sourceSlot = await comfyPage.getSubgraphInputSlot()
const calculatedSourcePos = await sourceSlot.getOpenSlotPosition()
await comfyMouse.move(calculatedSourcePos)
await comfyMouse.drag(positiveInputPos)
await comfyMouse.drop()
// Verify connection went to the correct slot
const positiveLinks = await positiveInput.getLinkCount()
const negativeLinks = await negativeInput.getLinkCount()
expect(positiveLinks).toBe(1)
expect(negativeLinks).toBe(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 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: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,33 @@
---
globs:
- '**/*.spec.ts'
---
# Playwright E2E Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Best Practices
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
- Prefer specific selectors (role, label, test-id)
- Test across viewports
## Test Tags
Tags are respected by config:
- `@mobile` - Mobile viewport tests
- `@2x` - High DPI tests
## Test Data
- Check `browser_tests/assets/` for test data and fixtures
- Use realistic ComfyUI workflows for E2E tests
## Running Tests
```bash
pnpm test:browser # Run all E2E tests
pnpm test:browser -- --ui # Interactive UI mode
```

View File

@@ -0,0 +1,55 @@
---
globs:
- '**/*.stories.ts'
---
# Storybook Conventions
## File Placement
Place `*.stories.ts` files alongside their components:
```
src/components/MyComponent/
├── MyComponent.vue
└── MyComponent.stories.ts
```
## Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: { layout: 'centered' }
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { /* props */ }
}
```
## Required Story Variants
Include when applicable:
- **Default** - Minimal props
- **WithData** - Realistic data
- **Loading** - Loading state
- **Error** - Error state
- **Empty** - No data
## Mock Data
Use realistic ComfyUI schemas for mocks (node definitions, components).
## Running Storybook
```bash
pnpm storybook # Development server
pnpm build-storybook # Production build
```

View File

@@ -0,0 +1,37 @@
---
globs:
- '**/*.ts'
- '**/*.tsx'
- '**/*.vue'
---
# TypeScript Conventions
## Type Safety
- Never use `any` type - use proper TypeScript types
- Never use `as any` type assertions - fix the underlying type issue
- Type assertions are a last resort; they lead to brittle code
- Avoid `@ts-expect-error` - fix the underlying issue instead
## Utility Libraries
- Use `es-toolkit` for utility functions (not lodash)
## API Utilities
When making API calls in `src/`:
```typescript
// ✅ Correct - use api helpers
const response = await api.get(api.apiURL('/prompt'))
const template = await fetch(api.fileURL('/templates/default.json'))
// ❌ Wrong - direct URL construction
const response = await fetch('/api/prompt')
```
## Security
- Sanitize HTML with `DOMPurify.sanitize()`
- Never log secrets or sensitive data

36
docs/guidance/vitest.md Normal file
View File

@@ -0,0 +1,36 @@
---
globs:
- '**/*.test.ts'
---
# Vitest Unit Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Test Quality
- Do not write change detector tests (tests that just assert defaults)
- Do not write tests dependent on non-behavioral features (styles, classes)
- Do not write tests that just test mocks - ensure real code is exercised
- Be parsimonious; avoid redundant tests
## Mocking
- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`)
- Keep module mocks contained - no global mutable state
- Use `vi.hoisted()` for per-test mock manipulation
- Don't mock what you don't own
## Component Testing
- Use Vue Test Utils for component tests
- Follow advice about making components easy to test
- Wait for reactivity with `await nextTick()` after state changes
## Running Tests
```bash
pnpm test:unit # Run all unit tests
pnpm test:unit -- path/to/file # Run specific test
pnpm test:unit -- --watch # Watch mode
```

View File

@@ -0,0 +1,46 @@
---
globs:
- '**/*.vue'
---
# Vue Component Conventions
Applies to all `.vue` files anywhere in the codebase.
## Vue 3 Composition API
- Use `<script setup lang="ts">` for component logic
- Destructure props (Vue 3.5 style with defaults) like `const { color = 'blue' } = defineProps<...>()`
- Use `ref`/`reactive` for state
- Use `computed()` for derived state
- Use lifecycle hooks: `onMounted`, `onUpdated`, etc.
## Component Communication
- Prefer `emit/@event-name` for state changes (promotes loose coupling)
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
- Proper props and emits definitions
## VueUse Composables
Prefer VueUse composables over manual event handling:
- `useElementHover` instead of manual mouseover/mouseout listeners
- `useIntersectionObserver` for visibility detection instead of scroll handlers
- `useFocusTrap` for modal/dialog focus management
- `useEventListener` for auto-cleanup event listeners
Prefer Vue native options when available:
- `defineModel` instead of `useVModel` for two-way binding with props
## Styling
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
## Best Practices
- Extract complex conditionals to `computed`
- In unmounted hooks, implement cleanup for async operations

View File

@@ -30,10 +30,6 @@ 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

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

View File

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

View File

@@ -3,6 +3,13 @@
"short_name": "ComfyUI",
"description": "ComfyUI: AI image generation platform",
"start_url": "/",
"icons": [
{
"src": "/assets/images/comfy-logo-single.svg",
"sizes": "any",
"type": "image/svg+xml"
}
],
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.10",
"version": "1.38.8",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -22,10 +22,8 @@
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",
@@ -63,14 +61,12 @@
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@trivago/prettier-plugin-sort-imports": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
@@ -101,11 +97,11 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"pretty-bytes": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",
@@ -192,5 +188,10 @@
"yjs": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
}
}
}

855
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,6 @@ catalog:
'@nx/vite': 22.2.6
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.57.0
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
@@ -33,7 +32,6 @@ catalog:
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12
'@trivago/prettier-plugin-sort-imports': ^5.2.0
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
@@ -70,12 +68,12 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
prettier: ^3.7.4
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
@@ -93,7 +91,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^7.3.0
vite: ^8.0.0-beta.8
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -12,6 +12,7 @@ declare global {
const __ALGOLIA_API_KEY__: string
const __USE_PROD_CONFIG__: boolean
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
const __IS_NIGHTLY__: boolean
}
type GlobalWithDefines = typeof globalThis & {
@@ -22,6 +23,7 @@ type GlobalWithDefines = typeof globalThis & {
__ALGOLIA_API_KEY__: string
__USE_PROD_CONFIG__: boolean
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
__IS_NIGHTLY__: boolean
window?: Record<string, unknown>
}
@@ -36,6 +38,7 @@ globalWithDefines.__ALGOLIA_APP_ID__ = ''
globalWithDefines.__ALGOLIA_API_KEY__ = ''
globalWithDefines.__USE_PROD_CONFIG__ = false
globalWithDefines.__DISTRIBUTION__ = 'localhost'
globalWithDefines.__IS_NIGHTLY__ = false
// Provide a minimal window shim for Node environment
// This is needed for code that checks window existence during imports

26
src/AGENTS.md Normal file
View File

@@ -0,0 +1,26 @@
# Source Code Guidelines
## Error Handling
- User-friendly and actionable messages
- Proper error propagation
## Security
- Sanitize HTML with DOMPurify
- Validate trusted sources
- Never log secrets
## State Management (Stores)
- Follow domain-driven design for organizing files/folders
- Clear public interfaces
- Restrict extension access
- Clean up subscriptions
## General Guidelines
- Use `es-toolkit` for utility functions
- Use TypeScript for type safety
- Avoid `@ts-expect-error` - fix the underlying issue
- Use `vue-i18n` for ALL user-facing strings (`src/locales/en/main.json`)

View File

@@ -1,57 +1,3 @@
# Source Code Guidelines
## Service Layer
### API Calls
- Use `api.apiURL()` for backend endpoints
- Use `api.fileURL()` for static files
#### ✅ Correct Usage
```typescript
// Backend API call
const response = await api.get(api.apiURL('/prompt'))
// Static file
const template = await fetch(api.fileURL('/templates/default.json'))
```
#### ❌ Incorrect Usage
```typescript
// WRONG - Direct URL construction
const response = await fetch('/api/prompt')
const template = await fetch('/templates/default.json')
```
### Error Handling
- User-friendly and actionable messages
- Proper error propagation
### Security
- Sanitize HTML with DOMPurify
- Validate trusted sources
- Never log secrets
## State Management (Stores)
### Store Design
- Follow domain-driven design
- Clear public interfaces
- Restrict extension access
### Best Practices
- Use TypeScript for type safety
- Implement proper error handling
- Clean up subscriptions
- Avoid @ts-expect-error
## General Guidelines
- Use es-toolkit for utility functions
- Implement proper TypeScript types
- Follow Vue 3 composition API style guide
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`
<!-- We forked the path, yet here we are again—
Maintaining two files where one would have been sane. -->
@AGENTS.md

View File

@@ -1 +1,21 @@
@import '@comfyorg/design-system/css/style.css';
@import '@comfyorg/design-system/css/style.css';
@media (prefers-reduced-motion: no-preference) {
/* List transition animations */
.list-scale-move,
.list-scale-enter-active,
.list-scale-leave-active {
transition: opacity 150ms ease, transform 150ms ease;
}
.list-scale-enter-from,
.list-scale-leave-to {
opacity: 0;
transform: scale(70%);
}
.list-scale-leave-active {
position: absolute;
width: 100%;
}
}

6
src/components/AGENTS.md Normal file
View File

@@ -0,0 +1,6 @@
# Component Guidelines
## Component Communication
- Prefer `emit/@event-name` for state changes
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)

View File

@@ -1,45 +1,3 @@
# Component Guidelines
## Vue 3 Composition API
- Use setup() function
- Destructure props (Vue 3.5 style)
- Use ref/reactive for state
- Implement computed() for derived state
- Use provide/inject for dependency injection
## Component Communication
- Prefer `emit/@event-name` for state changes
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
- Events promote loose coupling
## UI Framework
- Deprecated PrimeVue component replacements:
- Dropdown → Select
- OverlayPanel → Popover
- Calendar → DatePicker
- InputSwitch → ToggleSwitch
- Sidebar → Drawer
- Chips → AutoComplete with multiple enabled
- TabMenu → Tabs without panels
- Steps → Stepper without panels
- InlineMessage → Message
## Styling
- Use Tailwind CSS only (no custom CSS)
- Use the correct tokens from style.css in the design system package
- For common operations, try to use existing VueUse composables that automatically handle effect scope
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
## Best Practices
- Extract complex conditionals to computed
- Implement cleanup for async operations
- Use vue-i18n for ALL UI strings
- Use lifecycle hooks: onMounted, onUpdated
- Use Teleport/Suspense when needed
- Proper props and emits definitions
<!-- "Play nice with others," mother always said,
But Claude prefers its own file name instead. -->
@AGENTS.md

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full items-center">
<div class="flex h-full items-center" :class="cn(!isDocked && '-ml-2')">
<div
v-if="isDragging && !isDocked"
:class="actionbarClass"
@@ -77,7 +77,6 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
@@ -88,14 +87,7 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
if (event.y < minY) {
event.y = minY
}
}
containerElement: document.body
})
// Update storedPosition when x or y changes

View File

@@ -1,206 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import WorkspaceAuthGate from './WorkspaceAuthGate.vue'
const mockIsInitialized = ref(false)
const mockCurrentUser = ref<object | null>(null)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
isInitialized: mockIsInitialized,
currentUser: mockCurrentUser
})
}))
const mockRefreshRemoteConfig = vi.fn()
vi.mock('@/platform/remoteConfig/refreshRemoteConfig', () => ({
refreshRemoteConfig: (options: unknown) => mockRefreshRemoteConfig(options)
}))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
const mockWorkspaceStoreInitialize = vi.fn()
const mockWorkspaceStoreInitState = vi.hoisted(() => ({
value: 'uninitialized' as string
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get initState() {
return mockWorkspaceStoreInitState.value
},
initialize: mockWorkspaceStoreInitialize
})
}))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('primevue/progressspinner', () => ({
default: { template: '<div class="progress-spinner" />' }
}))
describe('WorkspaceAuthGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsInitialized.value = false
mockCurrentUser.value = null
mockTeamWorkspacesEnabled.value = false
mockWorkspaceStoreInitState.value = 'uninitialized'
mockRefreshRemoteConfig.mockResolvedValue(undefined)
mockWorkspaceStoreInitialize.mockResolvedValue(undefined)
})
const mountComponent = () =>
mount(WorkspaceAuthGate, {
slots: {
default: '<div data-testid="slot-content">App Content</div>'
}
})
describe('non-cloud builds', () => {
it('renders slot immediately when isCloud is false', async () => {
mockIsCloud.value = false
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(wrapper.find('.progress-spinner').exists()).toBe(false)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - unauthenticated user', () => {
it('shows spinner while waiting for Firebase auth', () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
})
it('renders slot when Firebase initializes with no user', async () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
mockIsInitialized.value = true
mockCurrentUser.value = null
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - authenticated user', () => {
beforeEach(() => {
mockIsInitialized.value = true
mockCurrentUser.value = { uid: 'user-123' }
})
it('refreshes remote config with auth after Firebase init', async () => {
mountComponent()
await flushPromises()
expect(mockRefreshRemoteConfig).toHaveBeenCalledWith({ useAuth: true })
})
it('renders slot when teamWorkspacesEnabled is false', async () => {
mockTeamWorkspacesEnabled.value = false
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
})
it('initializes workspace store when teamWorkspacesEnabled is true', async () => {
mockTeamWorkspacesEnabled.value = true
const wrapper = mountComponent()
await flushPromises()
expect(mockWorkspaceStoreInitialize).toHaveBeenCalled()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
it('skips workspace init when store is already initialized', async () => {
mockTeamWorkspacesEnabled.value = true
mockWorkspaceStoreInitState.value = 'ready'
const wrapper = mountComponent()
await flushPromises()
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
})
describe('error handling - graceful degradation', () => {
beforeEach(() => {
mockIsInitialized.value = true
mockCurrentUser.value = { uid: 'user-123' }
})
it('renders slot when remote config refresh fails', async () => {
mockRefreshRemoteConfig.mockRejectedValue(new Error('Network error'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
it('renders slot when remote config refresh times out', async () => {
vi.useFakeTimers()
// Never-resolving promise simulates a hanging request
mockRefreshRemoteConfig.mockReturnValue(new Promise(() => {}))
const wrapper = mountComponent()
await flushPromises()
// Still showing spinner before timeout
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
// Advance past the 10 second timeout
await vi.advanceTimersByTimeAsync(10_001)
await flushPromises()
// Should render slot after timeout (graceful degradation)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
vi.useRealTimers()
})
it('renders slot when workspace store initialization fails', async () => {
mockTeamWorkspacesEnabled.value = true
mockWorkspaceStoreInitialize.mockRejectedValue(
new Error('Workspace init failed')
)
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
})
})

View File

@@ -1,129 +0,0 @@
<template>
<slot v-if="isReady" />
<div
v-else
class="fixed inset-0 z-[1100] flex items-center justify-center bg-[var(--p-mask-background)]"
>
<ProgressSpinner />
</div>
</template>
<script setup lang="ts">
/**
* WorkspaceAuthGate - Conditional auth checkpoint for workspace mode.
*
* This gate ensures proper initialization order for workspace-scoped auth:
* 1. Wait for Firebase auth to resolve
* 2. Check if teamWorkspacesEnabled feature flag is on
* 3. If YES: Initialize workspace token and store before rendering
* 4. If NO: Render immediately using existing Firebase auth
*
* This prevents race conditions where API calls use Firebase tokens
* instead of workspace tokens when the workspace feature is enabled.
*/
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
const isReady = ref(!isCloud)
async function initialize(): Promise<void> {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const { isInitialized, currentUser } = storeToRefs(authStore)
try {
// Step 1: Wait for Firebase auth to resolve
// This is shared with router guard - both wait for the same thing,
// but this gate blocks rendering while router guard blocks navigation
if (!isInitialized.value) {
await until(isInitialized).toBe(true, {
timeout: FIREBASE_INIT_TIMEOUT_MS
})
}
// Step 2: If not authenticated, nothing more to do
// Unauthenticated users don't have workspace context
if (!currentUser.value) {
isReady.value = true
return
}
// Step 3: Refresh feature flags with auth context
// This ensures teamWorkspacesEnabled reflects the authenticated user's state
// Timeout prevents hanging if server is slow/unresponsive
try {
await Promise.race([
refreshRemoteConfig({ useAuth: true }),
promiseTimeout(CONFIG_REFRESH_TIMEOUT_MS).then(() => {
throw new Error('Config refresh timeout')
})
])
} catch (error) {
console.warn(
'[WorkspaceAuthGate] Failed to refresh remote config:',
error
)
// Continue - feature flags will use defaults (teamWorkspacesEnabled=false)
// App will render with Firebase auth fallback
}
// Step 4: THE CHECKPOINT - Are we in workspace mode?
const { flags } = useFeatureFlags()
if (!flags.teamWorkspacesEnabled) {
// Not in workspace mode - use existing Firebase auth flow
// No additional initialization needed
isReady.value = true
return
}
// Step 5: WORKSPACE MODE - Full initialization
await initializeWorkspaceMode()
} catch (error) {
console.error('[WorkspaceAuthGate] Initialization failed:', error)
} finally {
// Always render (graceful degradation)
// If workspace init failed, API calls fall back to Firebase token
isReady.value = true
}
}
async function initializeWorkspaceMode(): Promise<void> {
// Initialize the full workspace store which handles:
// - Restoring workspace token from session (fast path for refresh)
// - Fetching workspace list
// - Switching to last used workspace if needed
// - Setting active workspace
try {
const workspaceStore = useTeamWorkspaceStore()
if (workspaceStore.initState === 'uninitialized') {
await workspaceStore.initialize()
}
} catch (error) {
// Log but don't block - workspace UI features may not work but app will render
// API calls will fall back to Firebase token
console.warn(
'[WorkspaceAuthGate] Failed to initialize workspace store:',
error
)
}
}
// Initialize on mount. This gate should be placed on the authenticated layout
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
// The router guard ensures only authenticated users reach this layout.
onMounted(() => {
void initialize()
})
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const x = computed({
get: () => modelValue.value.x,
set: (x) => {
modelValue.value = { ...modelValue.value, x }
}
})
const y = computed({
get: () => modelValue.value.y,
set: (y) => {
modelValue.value = { ...modelValue.value, y }
}
})
const width = computed({
get: () => modelValue.value.width,
set: (width) => {
modelValue.value = { ...modelValue.value, width }
}
})
const height = computed({
get: () => modelValue.value.height,
set: (height) => {
modelValue.value = { ...modelValue.value, height }
}
})
</script>

View File

@@ -1,6 +1,11 @@
<template>
<div class="relative inline-flex items-center">
<Button size="icon" variant="secondary" @click="popover?.toggle">
<Button
size="icon"
variant="secondary"
v-bind="$attrs"
@click="popover?.toggle"
>
<i
:class="
cn(
@@ -60,6 +65,10 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
interface MoreButtonProps {
isVertical?: boolean
}

View File

@@ -51,7 +51,7 @@ describe('EditableText', () => {
isEditing: true
})
await wrapper.findComponent(InputText).setValue('New Text')
await wrapper.findComponent(InputText).trigger('keydown.enter')
await wrapper.findComponent(InputText).trigger('keyup.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('keydown.escape')
await wrapper.findComponent(InputText).trigger('keyup.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('keydown.escape')
await wrapper.findComponent(InputText).trigger('keyup.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('keydown.enter')
await enterWrapper.findComponent(InputText).trigger('keyup.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('keydown.escape')
await escapeWrapper.findComponent(InputText).trigger('keyup.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 keydown.enter is triggered -->
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
<InputText
v-else
ref="inputRef"
@@ -18,8 +18,8 @@
...inputAttrs
}
}"
@keydown.enter.capture.stop="blurInputElement"
@keydown.escape.capture.stop="cancelEditing"
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@contextmenu.stop
@pointerdown.stop.capture

View File

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

View File

@@ -28,7 +28,7 @@
/>
</div>
<div
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
pt:text="w-full"
>
<div class="flex items-center justify-between">
<div>
<div data-testid="current-user-indicator">
{{ $t('g.currentUser') }}: {{ userStore.currentUser?.username }}
</div>
<Button

View File

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

View File

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

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

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